diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a7733..9b13043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,19 @@ ### Added -- **Agentic Git 偏好配置(Agentic Git 预置)**:新增 `hyperGit.agent.*` 四项配置为 M5 [AI 接缝](./docs/research/05-ai-agent-seams.md) 铺路——① `hyperGit.agent.baseBranch`(创建 PR 的基线分支,留空=仓库默认分支);②③④ `commitPreferences` / `createPrPreferences` / `reviewPreferences`(分别为点击 **Commit / Create PR / Review** 时发送给 agent 的自定义指令,均以 `editPresentation: "multilineText"` 多行文本域承载、**内置默认模板**)。其中 **Commit 模板由中文提交规约译为专业英文**(规约规定提交信息用中文,故示例与签名块原样保留);Create PR / Review 模板**逐字内置**(含 `${TARGET_BRANCH}` / `#{pr_number}` 等占位符)。采用**原生设置**承载(零新增 Webview / 命令 / TS / 测试),默认值内联于 [`package.json`](./package.json)。**仅落地配置与内置默认**,`agent/` 五接缝与 `hyperGit.ai.enabled` 行为不变、零破坏。README 指标行同步 `7 → 11 配置项`。完整叙述见 [Agentic Git 偏好配置](./docs/features/agentic-git-preferences.md)。 -- **Claude Code 配置(Agentic Git 预置)**:新增两项与 Claude Code 相关的配置为 M5 [AI 接缝](./docs/research/05-ai-agent-seams.md) 铺路——① 设置项 `hyperGit.claudeCode.executablePath`(Claude Code CLI 可执行路径,**留空=从 `PATH` 自动探测 `claude`**;描述内嵌 `command:` 链接就地提供「浏览 / 打开 Claude 设置」);② 命令 `hyperGit.setClaudeCodePath`(QuickPick:Browse 文件选择器写回 / **Use system Claude Code** 清空覆盖)与 `hyperGit.openClaudeSettings`(打开 `~/.claude/settings.json`,缺失时经确认创建 `{}`)。采用**原生设置 + 命令**承载(零新增 Webview 面板),路径解析下沉纯函数 [`engine/agent/claude-path.ts`](./src/engine/agent/claude-path.ts)(Vitest 可测),命令实现见 [`adapter/claude-commands.ts`](./src/adapter/claude-commands.ts)。**仅落地配置与快捷入口**,`agent/` 五接缝与 `hyperGit.ai.enabled` 行为不变、零破坏。同步校正 README 指标行(`6 视图 · 101 命令 · 7 配置项 · 372 单元测试`,此前 `97 命令 / 324 测试` 已滞后 HEAD)。完整叙述见 [Claude Code 配置](./docs/features/claude-code-config.md)。 -- **Branches 视图按 `/` 前缀分组为文件夹树**:Local / Remote / Tags 三段支持在**平铺**与**按 `/` 前缀分组的可折叠文件夹树**间切换——共享前缀的分支收拢到以前缀命名的文件夹下、叶子仅显示末段后缀(如 `bak/2025`、`bak/master-2025-07` → 📁`bak` → `2025` / `master-2025-07`;远程整体收拢为 `origin` 文件夹并内嵌 `feature`)。新增纯逻辑引擎 [`buildRefTree`](./src/engine/ref/ref-tree.ts)(复用「变更文件目录树」`buildFileTree` 的 trie 构建 + compact 折叠范式;叶子携完整 `RawRef`、命令定位不受影响;分支感知排序:当前 HEAD → 收藏 → 同档文件夹优先、名称数字感知升序),并**隐藏 `origin/HEAD` 符号引用**(对齐主流 Git UI,消除 `origin` 叶子与 `origin` 文件夹同名冲突)。工具栏提供「平铺 ⇄ 树」切换(`hyperGit.branchesGroupByPrefix` / `hyperGit.branchesFlatten`,context key 驱动按钮互斥),偏好按仓库持久化于 `workspaceState`(默认树形),文件夹默认展开、展开态跨刷新稳定。文件夹节点用独立 `contextValue` 且不登记任何右键命令,checkout / delete / rename / merge / rebase / compare / favorite 与多选批量均零改动、零破坏。完整叙述见 [分支前缀分组树](./docs/features/branch-tree-group-by-prefix.md)。 +- **多根工作区仓库切换(issue #107)**:多根工作区含多个 Git 仓库时,Graph 工具栏仓库名升级为**可切换按钮**(对标 Git Graph;单仓库退化为纯文本,多数用户零感知),Command Palette 亦可经 `Hyper Git: Select Repository…` 直达。切换**全局活跃仓库**——Graph / Branches / Commit(changelist)/ Stash / Shelf / Worktrees / 未提交角标七视图级联跟随,活跃仓库持久化于 `workspaceState`(`hyperGit.activeRepoRoot`,重开窗口回到上次操作的仓库)。**按仓库隔离记忆**:changelist 分配 / 分支收藏 / 分支分组偏好(`setRepoRoot` 重绑 + context key 同步)、Shelf 存储(`shelves/./` 随活跃仓库动态求值,旧平铺数据一次性安全迁移:仅 rename、零删除零覆盖)、最近提交消息(per-repo key,旧全局 key 回落一次)、Graph/Commit webview 视图状态(scope/选中/勾选集等 v2 `byRepo` 分区)。选取逻辑下沉纯函数 [`engine/git-state/repo-selection.ts`](./src/engine/git-state/repo-selection.ts)(持久化恢复 → folder0 匹配 → 首个仓库三级优先),**三重顺序不变量**保证 rebind 先于视图刷新;Blame 注解切库自清理;顺带修复激活竞态(repo 发现晚于 activate 时 memento key 落占位根)。集成测试新增 multi-root 双仓库 fixture 套件(`tests/suite/multi-root.test.js`,`activate()` 导出 `{ service }` 程序化接缝)。完整叙述见 [多根工作区仓库切换](./docs/features/multi-root-repo-selection.md)。 ### Changed +- **`@vscode/test-electron` 升级 3.0.0 → 3.1.0**:VS Code 1.110 起将 macOS 主二进制 `Electron` 更名为 `Code`(兼容符号链接于 2026-07 移除,microsoft/vscode#326502),3.0.0 启动 1.110+ 版本即 `spawn .../MacOS/Electron ENOENT` 崩溃;3.1.0 按 `Info.plist → CFBundleExecutable` 三级解析修复(microsoft/vscode-test#348)。此前 dependabot [PR #102](https://github.com/ThreeFish-AI/hyper-git/pull/102) 因 supply-chain 发布冷静期被 CI 拦截,现窗口已过正式合入。 - **面板视图默认布局调整(显隐与顺序)**:`package.json` `contributes.views` 声明式微调六视图默认态——**Stash / Shelf** 由 `collapsed` 改为 `visibility:"hidden"`(VS Code schema 中 `hidden` 映射 `hideByDefault`:默认不出现在容器内,仍可经视图标题右键菜单勾选恢复);**Commit / Worktrees** 由 `visible` 改为 `collapsed`(显示但默认折叠、点击标题即展开);**Branches / Graph** 保持 `visible`;容器内声明顺序调整为 **Commit · Branches · Graph · Worktrees**(Stash · Shelf 隐藏排其后)。`initialSize` 权重不变,`src/` 零改动,命令 / 协议 / 菜单 / `viewsWelcome` / 交互零破坏。新增 [`tests/unit/views-layout.test.ts`](./tests/unit/views-layout.test.ts) 声明式护栏锁定顺序与各视图 `visibility`。**平台约束**:`visibility` 仅影响全新安装 / 干净 profile 的初始态——老用户升级后需命令面板「View: Reset View Locations」或右键容器页签「Reset Location」方采用新默认(同 [issue #12](./docs/.agents/issue.md) / [#13](./docs/.agents/issue.md) 平台行为)。决策详见 [issue #16](./docs/.agents/issue.md)。 +### Added(既有未发布条目) + +- **Agentic Git 偏好配置(Agentic Git 预置)**:新增 `hyperGit.agent.*` 四项配置为 M5 [AI 接缝](./docs/research/05-ai-agent-seams.md) 铺路——① `hyperGit.agent.baseBranch`(创建 PR 的基线分支,留空=仓库默认分支);②③④ `commitPreferences` / `createPrPreferences` / `reviewPreferences`(分别为点击 **Commit / Create PR / Review** 时发送给 agent 的自定义指令,均以 `editPresentation: "multilineText"` 多行文本域承载、**内置默认模板**)。其中 **Commit 模板由中文提交规约译为专业英文**(规约规定提交信息用中文,故示例与签名块原样保留);Create PR / Review 模板**逐字内置**(含 `${TARGET_BRANCH}` / `#{pr_number}` 等占位符)。采用**原生设置**承载(零新增 Webview / 命令 / TS / 测试),默认值内联于 [`package.json`](./package.json)。**仅落地配置与内置默认**,`agent/` 五接缝与 `hyperGit.ai.enabled` 行为不变、零破坏。README 指标行同步 `7 → 11 配置项`。完整叙述见 [Agentic Git 偏好配置](./docs/features/agentic-git-preferences.md)。 +- **Claude Code 配置(Agentic Git 预置)**:新增两项与 Claude Code 相关的配置为 M5 [AI 接缝](./docs/research/05-ai-agent-seams.md) 铺路——① 设置项 `hyperGit.claudeCode.executablePath`(Claude Code CLI 可执行路径,**留空=从 `PATH` 自动探测 `claude`**;描述内嵌 `command:` 链接就地提供「浏览 / 打开 Claude 设置」);② 命令 `hyperGit.setClaudeCodePath`(QuickPick:Browse 文件选择器写回 / **Use system Claude Code** 清空覆盖)与 `hyperGit.openClaudeSettings`(打开 `~/.claude/settings.json`,缺失时经确认创建 `{}`)。采用**原生设置 + 命令**承载(零新增 Webview 面板),路径解析下沉纯函数 [`engine/agent/claude-path.ts`](./src/engine/agent/claude-path.ts)(Vitest 可测),命令实现见 [`adapter/claude-commands.ts`](./src/adapter/claude-commands.ts)。**仅落地配置与快捷入口**,`agent/` 五接缝与 `hyperGit.ai.enabled` 行为不变、零破坏。同步校正 README 指标行(`6 视图 · 101 命令 · 7 配置项 · 372 单元测试`,此前 `97 命令 / 324 测试` 已滞后 HEAD)。完整叙述见 [Claude Code 配置](./docs/features/claude-code-config.md)。 +- **Branches 视图按 `/` 前缀分组为文件夹树**:Local / Remote / Tags 三段支持在**平铺**与**按 `/` 前缀分组的可折叠文件夹树**间切换——共享前缀的分支收拢到以前缀命名的文件夹下、叶子仅显示末段后缀(如 `bak/2025`、`bak/master-2025-07` → 📁`bak` → `2025` / `master-2025-07`;远程整体收拢为 `origin` 文件夹并内嵌 `feature`)。新增纯逻辑引擎 [`buildRefTree`](./src/engine/ref/ref-tree.ts)(复用「变更文件目录树」`buildFileTree` 的 trie 构建 + compact 折叠范式;叶子携完整 `RawRef`、命令定位不受影响;分支感知排序:当前 HEAD → 收藏 → 同档文件夹优先、名称数字感知升序),并**隐藏 `origin/HEAD` 符号引用**(对齐主流 Git UI,消除 `origin` 叶子与 `origin` 文件夹同名冲突)。工具栏提供「平铺 ⇄ 树」切换(`hyperGit.branchesGroupByPrefix` / `hyperGit.branchesFlatten`,context key 驱动按钮互斥),偏好按仓库持久化于 `workspaceState`(默认树形),文件夹默认展开、展开态跨刷新稳定。文件夹节点用独立 `contextValue` 且不登记任何右键命令,checkout / delete / rename / merge / rebase / compare / favorite 与多选批量均零改动、零破坏。完整叙述见 [分支前缀分组树](./docs/features/branch-tree-group-by-prefix.md)。 + ## [0.0.14] - 2026-07-06 — 修复 GRAPH 视图提交记录时间倒序 修复 GRAPH 视图「All」范围下提交记录未按时间倒序排列的问题——取数参数由 `--topo-order` 改为 `--author-date-order`,令跨分支提交按作者日期正确交错、消除「日期回跳」。纯取数排序修复,不改数据协议、布局算法与任何视图交互。完整用户视角叙述见 [Release Note v0.0.14](./docs/releases/v0.0.14.md)。 diff --git a/README.md b/README.md index 5b2c227..95dbf61 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ English · 简体中文

-

6 views · 101 commands · 11 settings · 375 unit tests

+

6 views · 102 commands · 11 settings · 403 unit tests

## Features @@ -25,6 +25,7 @@

- **Multi-Changelist Changes view**: group edits into named lists, mark one as the active commit target, create / rename / delete / move lists, persisted in `workspaceState` (restored across restarts); status colors reuse the `gitDecoration.*` theme tokens. +- **Multi-root repository switching**: in multi-root workspaces with several Git repositories, the Graph toolbar's repository name becomes a clickable switcher (Git Graph-style) plus a `Select Repository…` command; one switch updates the active repository across all views (Graph / Branches / Commit / Stash / Shelf / Worktrees / badge), with changelists, branch favorites, grouping preferences, shelves, and recent messages remembered per repository, and the last active repository restored across restarts. - **Commit panel**: a hand-built commit view with real-time Conventional Commits validation, Amend / Sign-off / skip hooks, and Commit / Commit & Push; the checkbox selection is the authoritative commit scope, and recent messages are reusable. - **Log commit graph (hand-rendered DAG)**: colored swimlanes, branch/merge edges, HEAD/branch/tag badges, virtualized incremental loading; 7 composable filters (author / path / grep / regex / merge-mode / date / clear); per-commit actions (Reset, new branch·tag, Cherry-Pick, Revert, list containing branches). - **Branches management**: favorites / local / remote / tags grouped into four sections with ahead·behind·upstream tracking; **prefix folder grouping** (flat ⇄ tree toggle) that collapses `/`-shared branches under their common prefix, showing only the suffix; create / checkout / delete / rename / merge / rebase / compare / favorite; **multi-select batch** delete, copy ref, and favorite (with an honest merged/unmerged split confirmation). @@ -78,7 +79,7 @@ See the [Engineering Plan](./docs/architecture/engineering-plan.md), the [Implem pnpm install # install dependencies pnpm run compile # type-check + lint + build pnpm run watch # watch build (press F5 to launch the Extension Host debugger) -pnpm run test:unit # unit tests (pure engine logic, Vitest, 375 cases) +pnpm run test:unit # unit tests (pure engine logic, Vitest, 403 cases) pnpm run test:integration # integration tests (@vscode/test-electron) pnpm run package # production build pnpm dlx @vscode/vsce package # package the .vsix diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 628942c..c0ba737 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -140,3 +140,11 @@ - **同类问题影响**:所有以 `contributes.views` 声明默认布局的 VS Code 扩展;凡将「默认隐藏但可恢复」误用 `when:false`(致用户无法启用)或反向混淆的实现;以及把「仅初始态生效」误当「持久强制」而困惑于老用户升级后不生效的排障。 + +## #107 多根工作区无法切换 Git 仓库(Graph 等全部视图锁定单一仓库) + +- **表因**:多根工作区(multi-root)含多个 Git 仓库时,Graph 面板恒只显示其中一个仓库(`workspaceFolders[0]` 匹配或 `repositories[0]`),无任何切换入口;Git Graph / GitLens 等均有仓库选择器。 +- **根因**:扩展的活跃仓库模型是**单仓库假设**——① `GitRepositoryService.pickRepository()` 只按「首个 folder → 首个仓库」两级选取,无手动切换 API;② 4 处**构造期冻结**的按 repoRoot 键控持久化状态(`ChangelistRegistry` / `BranchFavorites` / `BranchesTreeProvider.groupingKey` / `ShelfService` 目录)不随仓库变化,即使切了活跃仓库,changelist 分配与收藏仍指向旧仓库、Shelf 会跨库误 apply;③ Blame 注解、Graph host 级 filter、webview 持久化状态等均无切库清理/隔离。 +- **处理方式**:落地全局活跃仓库切换(Git Graph 模式)——① 选取逻辑下沉纯函数 `engine/git-state/repo-selection.ts`(三级优先:持久化恢复 → folder0 匹配 → 首个仓库,路径归一化跨平台稳定),`GitRepositoryService` 新增 `selectRepository`/`listRepositories`/`onDidChangeRepository`,活跃仓库持久化 `hyperGit.activeRepoRoot`;② **三重顺序不变量**保证 rebind 先于刷新:`applyRepository` 内先 fire 切换事件(同步 rebind:registry/favorites/branchesTree.setRepoRoot + context key 同步 + blame 清理 + filter 清空)后 fire onDidChange(防抖刷新),且 rebind 订阅先于 `refreshAll` 注册;③ Shelf 目录随 `service.repoRoot` 动态求值(`shelves/./`)+ 旧平铺数据一次性安全迁移(仅 rename、目标存在即跳过、失败可重试);④ Graph 工具栏仓库名升级可点击按钮(单仓库退化纯文本)+ `hyperGit.selectRepository` palette 命令(带参=程序化切换接缝,`activate()` 导出 `{ service }` 供集成测试);⑤ webview 视图状态(scope/选中/勾选集等)v2 `byRepo` 分区 + 最近提交消息 per-repo key(旧 key 回落一次平滑迁移)。集成测试新增 multi-root 双仓库 fixture 套件(`tests/suite/multi-root.test.js`)。 +- **后续防范**:① **新增组件若构造期快照 repoRoot(拼 memento key / 存储目录),必须订阅 `service.onDidChangeRepository` 重绑**——可用 `grep -rn 'repoRoot' src/adapter/ | grep -v 'service.repoRoot'` 扫描快照点;② 集成测试需 UI 交互时,优先给命令 handler 加可选参数(无参=QuickPick,带参=程序化)+ `activate()` 导出接缝,勿依赖无法自动化的原生弹窗;③ 顺带修复了既有激活竞态(repo 发现晚于 activate 时 memento key 落 workspaceRoot 占位),rebind 事件会纠正——但根治应在「窗口内无写入路径」前提下依赖该纠正,勿在激活早期引入持久化写入。 +- **同类问题影响**:所有围绕「单一活跃仓库」装配的 VS Code Git 扩展;凡按 repoRoot 构造 workspaceState/globalStorage 键或存储目录、却不在仓库集合变化时重绑的实现;以及 macOS 上 VS Code ≥ 1.110 主二进制 `Electron`→`Code` 更名导致 `@vscode/test-electron` 旧版 ENOENT 的集成测试环境(升 3.1.0 解决,见 PR #102)。 diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 2993b82..035f4d4 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -24,6 +24,7 @@ - [Log 提交悬浮详情](../docs/features/log-commit-tooltip.md) — 悬停提交行浮层展示 分支/标签/HEAD/完整消息/作者·提交者/时间/SHA;复用 CI 浮层范式、与其互斥。 - [Claude Code 配置](../docs/features/claude-code-config.md) — Agentic Git 预置:`hyperGit.claudeCode.executablePath` 设置 + `~/.claude/settings.json` 快捷入口(原生设置 + 命令;纯路径逻辑沉淀 `engine/agent/`)。 - [Agentic Git 偏好配置](../docs/features/agentic-git-preferences.md) — Agentic Git 预置:`hyperGit.agent.*` 四项(baseBranch + commit/createPr/review Preferences;多行文本域 + 内置默认模板,Commit 模板中文规约已英译)。 +- [多根工作区仓库切换](../docs/features/multi-root-repo-selection.md) — Graph 工具栏仓库名按钮 + `hyperGit.selectRepository` 切换全局活跃仓库:七视图级联跟随、changelist/收藏/Shelf/最近消息按仓库隔离、三重顺序不变量与安全迁移。 - [实施状态总览](../docs/milestones/implementation-status.md) — M0-M5 交付记录 + API 限制 + M5 AI 设计 + 验证/发布(**实施看板**)。 - [工程实施方案](../docs/architecture/engineering-plan.md) — 路径 B 架构 + M0-M5 里程碑(**开发蓝图**)。 - [Git 功能矩阵](../docs/requirements/idea-feature-matrix.md) — 56 功能点 / 8 组(**验收基线**,参考 IDEA 等成熟实现)。 diff --git a/docs/features/multi-root-repo-selection.md b/docs/features/multi-root-repo-selection.md new file mode 100644 index 0000000..24e8636 --- /dev/null +++ b/docs/features/multi-root-repo-selection.md @@ -0,0 +1,90 @@ +# 多根工作区仓库切换(Multi-root Repository Selection) + +> 多根工作区(multi-root)含多个 Git 仓库时,**Graph 工具栏仓库名升级为可切换按钮**(对标 Git Graph 的 repo selector),Command Palette 亦可直达。切换的是**全局活跃仓库**:Graph / Branches / Commit(changelist)/ Stash / Shelf / Worktrees / 未提交角标**全部视图跟随**,changelist 分配、分支收藏、分支分组偏好、Shelf 存储、最近提交消息**按仓库隔离记忆**,重开窗口回到上次操作的仓库。 + +## 交互形态 + +```mermaid +flowchart LR + subgraph TOOLBAR["Graph 工具栏"] + A["All · Current · Checkpoints"] --- B["repo-a ▾
(可点击按钮)"] + end + B -->|click + postMessage log/selectRepo| C["QuickPick
$(repo) repo-a /repo-a ✓
$(repo) repo-b /workspace/repo-b"] + C -->|选中| D["service.selectRepository(root)"] + D --> E["全局活跃仓库切换
七个视图 + 持久化状态级联跟随"] + style B fill:#1f6feb,color:#fff + style D fill:#238636,color:#fff + style E fill:#8957e5,color:#fff +``` + +- **入口一(主)**:Graph 工具栏右侧仓库名按钮——多仓库时显示 `basename ▾`、hover 高亮,单仓库退化为纯文本(现状观感,多数用户零感知); +- **入口二**:Command Palette → `Hyper Git: Select Repository…`(单仓库时提示 no-op); +- **持久化**:活跃仓库记于 `workspaceState`(`hyperGit.activeRepoRoot`,per-workspace 天然隔离),重开窗口恢复。 + +## 设计:全局活跃仓库 + 三重顺序不变量 + +沿用既有架构事实源——`GitRepositoryService` 是唯一活跃仓库持有者,全部 `execGit` 调用(cwd 恒为活跃仓库根)**零改动自动跟随**。核心新增是仓库切换的**级联时序**: + +```mermaid +sequenceDiagram + participant U as 用户 + participant S as GitRepositoryService + participant R as rebind 订阅
(extension.ts) + participant V as 视图刷新链 + + U->>S: selectRepository(rootB) + S->>S: applyRepository(repoB) + S-->>R: ① onDidChangeRepository.fire(同步) + R->>R: registry/favorites/branchesTree.setRepoRoot + R->>R: branchesGrouping context key 同步 + R->>R: blame 注解清理 · Graph filter 清空 + S-->>V: ② onDidChange.fire(其后) + V->>V: refreshAll(150ms 防抖)重取数据 +``` + +- **① 先于 ②**:rebind(换 memento key、重载状态)在同步栈完成,任何视图重取数据必然发生在 rebind 之后; +- **rebind 订阅先注册**:`extension.ts` 中先于 `service.onDidChange(refreshAll)` 挂载; +- **防抖兜底**:即使前两层被未来改动破坏,150ms/400ms 防抖刷新仍晚于同步 rebind。 + +**契约**:任何构造期快照 `repoRoot` 的新组件,必须订阅 `onDidChangeRepository` 重绑(见 `GitRepositoryService` 类 JSDoc)。 + +## 选取优先级(`engine/git-state/repo-selection.ts`) + +纯函数 `pickRepositoryRoot` 三级优先(`onDidOpenRepository` / `onDidCloseRepository` / 手动选择后均走此逻辑): + +1. **持久化恢复**:`hyperGit.activeRepoRoot` 仍存在于已发现仓库 → 恢复(消除 VS Code 发现顺序不稳定的漂移); +2. **首个工作区文件夹**命中的仓库(folder 为 git root 或位于某 repo 内); +3. **首个已发现仓库**(与旧版行为兼容)。 + +路径归一化(去尾分隔符 + Windows 盘符大小写不敏感)保证跨平台稳定比较。仓库被移除 → 规则 1 miss 自动回退并覆写持久化(自愈)。 + +## 按仓库隔离的持久化状态 + +| 状态 | 键 / 目录 | rebind 机制 | +| --- | --- | --- | +| Changelist 分配 | `hyperGit.changelists:${repoRoot}` | `setRepoRoot` 换 key 重载(幂等守卫 → 换 key → reload → fire) | +| 分支收藏 | `hyperGit.branchFavorites:${repoRoot}` | 同上 | +| 分支分组偏好 | `hyperGit.branchesGrouping:${repoRoot}` | 同上 + context key 同步 | +| Shelf 存储 | `shelves/./` | 目录随 `service.repoRoot` **动态求值**(零快照零双源) | +| 最近提交消息 | `hyperGit.recentCommitMessages:${repoRoot}` | 动态 key + 旧全局 key 回落一次(平滑迁移) | +| Graph 视图状态(scope/选中/详情模式) | webview `vscode.getState()` v2 `byRepo` 分区 | `graphData` 到达时按 `repoRoot` 换装载 | +| Commit 勾选集/视图模式 | 同上 | 同上(勾选是相对路径集合,跨仓库本就错位) | + +**Shelf 历史数据迁移**:旧版平铺 `shelves/*.json` 不分仓库,首次使用时一次性 `rename` 到当前活跃仓库子目录——**零删除、目标已存在即跳过(零覆盖)、失败可重试**;多仓库历史混仓数据无法事后归因,归属当前活跃仓库(Known Limitation)。 + +## 集成测试 + +`tests/suite/multi-root.test.js`(`run-integration.js` 构造 `repo-a`/`repo-b` 双仓库 `.code-workspace` 后追加第二段 VS Code 启动): + +- `hyperGit.selectRepository` 命令注册冒烟; +- 程序化切换(`activate()` 导出 `{ service }` 接缝):repoA ⇄ repoB 断言 `repoRoot` 跟随,切换后 refreshLog / refreshBranches / refresh 不抛错; +- 切换不存在路径返回 `false` 不炸; +- `listRepositories()` 返回两个仓库。 + +单仓库场景下选取算法与旧版行为完全一致(既有三套件零改动通过),单仓库用户零破坏。 + +## 已知限制 + +- 仓库切换时 Graph 的 host 级过滤条件(author/path/grep 等)清空——过滤条件是旧仓库语境的产物,跨仓库无意义; +- 多仓库历史混仓 Shelf 数据归属迁移时的当前活跃仓库(无法按仓库归因); +- CI 状态按 commit hash 缓存(跨仓库理论碰撞概率可忽略,行为正确)。 diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index 7387354..b9c00c1 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -16,7 +16,7 @@ English · 简体中文

-

6 视图 · 101 命令 · 11 配置项 · 375 单元测试

+

6 视图 · 102 命令 · 11 配置项 · 403 单元测试

## 核心能力 @@ -25,6 +25,7 @@

- **多 Changelist Changes 视图**:将改动分组到命名列表,设活动列表为提交目标,新建/重命名/删除/移动,`workspaceState` 持久化(重启恢复);状态色复用 `gitDecoration.*` 主题色。 +- **多根工作区仓库切换**:多根工作区含多个 Git 仓库时,Graph 工具栏仓库名升级为可切换按钮(对标 Git Graph)并提供 `Select Repository…` 命令;一次切换全局活跃仓库,Graph / Branches / Commit / Stash / Shelf / Worktrees / 角标全部视图跟随,changelist、分支收藏、分组偏好、Shelf、最近提交消息按仓库隔离记忆,重开窗口恢复上次活跃仓库。 - **Commit 提交窗口**:自绘提交面板 + Conventional Commits 实时校验 + Amend / Sign-off / 跳过 Hook + 提交 / 提交并推送;勾选集即提交权威范围;最近消息复用。 - **Log 提交图(自绘 DAG)**:彩色泳道、分叉·合并连线、HEAD/分支/标签徽标,虚拟滚动增量加载;7 个可组合过滤器(作者/路径/grep/正则/合并模式/日期/清除);per-commit 操作(Reset、新建分支·标签、Cherry-Pick、Revert、查看包含分支)。 - **Branches 分支管理**:收藏/本地/远程/标签四段分组 + ahead·behind·upstream 跟踪;**前缀文件夹分组**(平铺 ⇄ 树切换)将共享 `/` 前缀的分支收拢到共同前缀下、仅显示后缀;新建/检出/删除/重命名/合并/变基/比较/收藏;**多选批量**删除、复制引用、收藏(已合并/未合并诚实分栏确认)。 @@ -78,7 +79,7 @@ pnpm install # 安装依赖 pnpm run compile # 类型检查 + lint + 构建 pnpm run watch # 监听构建(F5 启动 Extension Host 调试) -pnpm run test:unit # 单元测试(engine 纯逻辑,Vitest,375 例) +pnpm run test:unit # 单元测试(engine 纯逻辑,Vitest,403 例) pnpm run test:integration # 集成测试(@vscode/test-electron) pnpm run package # 生产构建 pnpm dlx @vscode/vsce package # 打包 .vsix diff --git a/package.json b/package.json index d2eded3..a37bb79 100644 --- a/package.json +++ b/package.json @@ -182,6 +182,12 @@ "category": "Hyper Git", "icon": "$(refresh)" }, + { + "command": "hyperGit.selectRepository", + "title": "Select Repository…", + "category": "Hyper Git", + "icon": "$(repo)" + }, { "command": "hyperGit.ci.signIn", "title": "Sign In to GitHub for CI Status", @@ -1300,7 +1306,7 @@ "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^26.1.1", "@types/vscode": "~1.85.0", - "@vscode/test-electron": "^3.0.0", + "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.2.1", "esbuild": "^0.28.1", "eslint": "^10.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 348af5f..62ebdcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ~1.85.0 version: 1.85.0 '@vscode/test-electron': - specifier: ^3.0.0 - version: 3.0.0(supports-color@8.1.1) + specifier: ^3.1.0 + version: 3.1.0 '@vscode/vsce': specifier: ^3.2.1 version: 3.9.2(supports-color@8.1.1) @@ -667,8 +667,8 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - '@vscode/test-electron@3.0.0': - resolution: {integrity: sha512-TY5mC7aAjxSLDXsyjhrG8cJHgc/HLdiE5lvtW7hABYQrY24Qwozzr5UoO3HiuAM4Hzz4b7K/eZlwrCILj94CcA==} + '@vscode/test-electron@3.1.0': + resolution: {integrity: sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw==} engines: {node: '>=22'} '@vscode/vsce-sign-alpine-arm64@2.0.6': @@ -2887,8 +2887,8 @@ snapshots: '@typespec/ts-http-runtime@0.3.6': dependencies: - http-proxy-agent: 7.0.2(supports-color@8.1.1) - https-proxy-agent: 7.0.6(supports-color@8.1.1) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -2934,10 +2934,10 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vscode/test-electron@3.0.0(supports-color@8.1.1)': + '@vscode/test-electron@3.1.0': dependencies: - http-proxy-agent: 7.0.2(supports-color@8.1.1) - https-proxy-agent: 7.0.6(supports-color@8.1.1) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 jszip: 3.10.1 ora: 8.2.0 semver: 7.8.5 @@ -3600,14 +3600,14 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 - http-proxy-agent@7.0.2(supports-color@8.1.1): + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@8.1.1): + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@8.1.1) diff --git a/src/adapter/branch-favorites.ts b/src/adapter/branch-favorites.ts index a5c10c9..698bcb5 100644 --- a/src/adapter/branch-favorites.ts +++ b/src/adapter/branch-favorites.ts @@ -9,7 +9,7 @@ import { toggleFavorite } from '../engine/ref/favorites'; */ export class BranchFavorites implements vscode.Disposable { private names: string[]; - private readonly storageKey: string; + private storageKey: string; private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChange: vscode.Event = this._onDidChange.event; @@ -18,6 +18,17 @@ export class BranchFavorites implements vscode.Disposable { this.names = this.load(); } + /** 多根仓库切换 rebind(issue #107):换 storageKey 重载该仓库的收藏,幂等,切换后 fire 刷新。 */ + setRepoRoot(repoRoot: string): void { + const key = `hyperGit.branchFavorites:${repoRoot}`; + if (key === this.storageKey) { + return; + } + this.storageKey = key; + this.names = this.load(); + this._onDidChange.fire(); + } + private load(): string[] { const raw = this.workspaceState.get(this.storageKey); if (!raw) { diff --git a/src/adapter/changelist-registry.ts b/src/adapter/changelist-registry.ts index 56983b4..d3d6b2e 100644 --- a/src/adapter/changelist-registry.ts +++ b/src/adapter/changelist-registry.ts @@ -20,7 +20,7 @@ export class ChangelistRegistry implements vscode.Disposable { private defs: ChangelistDef[]; private activeId: string; private assignments: ChangelistAssignment; - private readonly storageKey: string; + private storageKey: string; private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChange: vscode.Event = this._onDidChange.event; @@ -32,6 +32,23 @@ export class ChangelistRegistry implements vscode.Disposable { this.assignments = loaded.assignments; } + /** + * 多根仓库切换 rebind(issue #107):换 storageKey 并重载该仓库的持久化状态。 + * 幂等(同 root 不动);切换后 fire onDidChange 并入 refreshAll 防抖刷新。 + */ + setRepoRoot(repoRoot: string): void { + const key = `hyperGit.changelists:${repoRoot}`; + if (key === this.storageKey) { + return; + } + this.storageKey = key; + const loaded = this.load(); + this.defs = loaded.defs; + this.activeId = loaded.activeId; + this.assignments = loaded.assignments; + this._onDidChange.fire(); + } + private load(): PersistedState { const fallback: PersistedState = { defs: [{ id: DEFAULT_ID, name: DEFAULT_NAME }], activeId: DEFAULT_ID, assignments: {} }; const raw = this.workspaceState.get(this.storageKey); diff --git a/src/adapter/commit/commit-service.ts b/src/adapter/commit/commit-service.ts index a88731b..e2f012e 100644 --- a/src/adapter/commit/commit-service.ts +++ b/src/adapter/commit/commit-service.ts @@ -40,7 +40,11 @@ export interface AiSeams { readonly conflict: IConflictResolver; } -const RECENT_KEY = 'hyperGit.recentCommitMessages'; +/** 旧版全局 key(升级前数据):per-repo key 未命中时回落读一次,平滑迁移不删旧数据。 */ +const RECENT_KEY_LEGACY = 'hyperGit.recentCommitMessages'; +/** per-repo key(issue #107 多根隔离):最近提交消息按仓库记忆。 */ +const recentKey = (repoRoot: string | null): string => + repoRoot ? `hyperGit.recentCommitMessages:${repoRoot}` : RECENT_KEY_LEGACY; const RECENT_MAX = 10; /** @@ -85,7 +89,12 @@ export class CommitService implements vscode.Disposable { } getRecentMessages(): readonly string[] { - const raw = this.workspaceState.get(RECENT_KEY); + // per-repo key 优先;未命中回落旧全局 key(升级平滑迁移,不删旧数据)。 + const key = recentKey(this.service.repoRoot); + let raw = this.workspaceState.get(key); + if (!raw && key !== RECENT_KEY_LEGACY) { + raw = this.workspaceState.get(RECENT_KEY_LEGACY); + } try { return raw ? (JSON.parse(raw) as string[]) : []; } catch { @@ -96,7 +105,7 @@ export class CommitService implements vscode.Disposable { private pushRecent(message: string): void { const trimmed = message.trim(); const list = [trimmed, ...this.getRecentMessages().filter((m) => m !== trimmed)].slice(0, RECENT_MAX); - void this.workspaceState.update(RECENT_KEY, JSON.stringify(list)); + void this.workspaceState.update(recentKey(this.service.repoRoot), JSON.stringify(list)); } /** 提交:stage 选中文件 → Checkin hook 链 → commit → 可选 push。 */ diff --git a/src/adapter/editor/blame-annotation.ts b/src/adapter/editor/blame-annotation.ts index b2639f3..6e679d1 100644 --- a/src/adapter/editor/blame-annotation.ts +++ b/src/adapter/editor/blame-annotation.ts @@ -31,6 +31,8 @@ export class BlameAnnotationController implements vscode.Disposable { } }), ); + // 活跃仓库切换(issue #107)后清除注解:blame 数据属于旧仓库,不清理会残留误导。 + this.disposables.push(service.onDidChangeRepository(() => this.clearAll())); } async toggle(): Promise { @@ -85,6 +87,15 @@ export class BlameAnnotationController implements vscode.Disposable { this.annotated.delete(uri.toString()); } + /** 清除全部注解(仓库切换时,annotated 中的 uri 逐个对可见编辑器复位装饰)。 */ + private clearAll(): void { + for (const key of [...this.annotated]) { + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === key); + editor?.setDecorations(this.decoration, []); + } + this.annotated.clear(); + } + dispose(): void { this.decoration.dispose(); this.disposables.forEach((d) => d.dispose()); diff --git a/src/adapter/git-repository-service.ts b/src/adapter/git-repository-service.ts index e4a2cfd..9a57ab0 100644 --- a/src/adapter/git-repository-service.ts +++ b/src/adapter/git-repository-service.ts @@ -4,8 +4,20 @@ import { logGit } from '../infra/git-console'; import type { API, Change, Repository } from '../types/git'; import { FileStatus } from '../engine/model'; import { countUniqueChanges, toRelKey } from '../engine/scm-mapping/change-count'; +import { normalizeRoot, pickRepositoryRoot } from '../engine/git-state/repo-selection'; import { mapGitStatus } from './git-status-map'; +/** workspaceState 持久化键:上次活跃仓库根路径(per-workspace 天然隔离)。 */ +const ACTIVE_REPO_KEY = 'hyperGit.activeRepoRoot'; + +/** 活跃仓库切换事件载荷。 */ +export interface ActiveRepositoryChange { + /** 切换前的仓库根(null = 此前无仓库)。 */ + readonly previousRoot: string | null; + /** 切换后的仓库根(null = 全部仓库已关闭)。 */ + readonly root: string | null; +} + /** 适配层视图模型:一个文件的变更(携带 vscode.Uri 供 diff/操作)。 */ export interface ChangeItem { /** 仓库相对路径(posix 分隔),用作 changelist 分组稳定 key。 */ @@ -20,15 +32,26 @@ export interface ChangeItem { /** * GitRepositoryService:封装 vscode.git 的活跃 Repository。 * 职责:选取活跃仓库、读取变更(→ ChangeItem)、暴露状态变更事件、提供 diff/写操作委托。 + * + * 多根工作区(issue #107):活跃仓库可经 {@link selectRepository} 手动切换并持久化 + * (`hyperGit.activeRepoRoot`,"last active" 语义)。**契约:任何构造期快照 repoRoot 的组件 + * 必须订阅 {@link onDidChangeRepository} 重绑**——该事件先于 onDidChange fire(同步完成 + * rebind,之后防抖刷新才重取数据)。 */ export class GitRepositoryService implements vscode.Disposable { private _repo: Repository | null = null; private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChange: vscode.Event = this._onDidChange.event; + private readonly _onDidChangeRepository = new vscode.EventEmitter(); + /** 活跃仓库切换(打开/关闭/手动选择/持久化恢复)。rebind 订阅必须先于 onDidChange 消费注册。 */ + readonly onDidChangeRepository: vscode.Event = this._onDidChangeRepository.event; private readonly disposables: vscode.Disposable[] = []; private repoSub?: vscode.Disposable; - constructor(private readonly api: API) { + constructor( + private readonly api: API, + private readonly workspaceState: vscode.Memento, + ) { this.disposables.push(api.onDidOpenRepository(() => this.pickRepository())); this.disposables.push(api.onDidCloseRepository(() => this.pickRepository())); this.pickRepository(); @@ -42,25 +65,58 @@ export class GitRepositoryService implements vscode.Disposable { return this._repo?.rootUri.fsPath ?? null; } - /** 选取活跃仓库:优先匹配工作区根(用 API.getRepository,路径段精确匹配),否则首个。 */ + /** + * 选取活跃仓库(引擎三级优先级:持久化恢复 → 首个 folder 匹配 → 首个仓库), + * 任何来源的成功选取都写回持久化("last active" 留痕,重开窗口回到上次操作的仓库)。 + * repoForFolder 注入:folder 为 git root 或 repo 内路径均命中(getRepository 同语义的 + * rootPath 等价实现,直接持有 api 处无需额外间接层)。 + */ private pickRepository(): void { - let repo: Repository | null = null; const folder = vscode.workspace.workspaceFolders?.[0]; - if (folder) { - repo = this.api.getRepository(folder.uri) ?? null; + const picked = pickRepositoryRoot({ + repos: this.api.repositories.map((r) => ({ rootPath: r.rootUri.fsPath, repo: r })), + firstWorkspaceFolder: folder?.uri.fsPath, + persistedRoot: this.workspaceState.get(ACTIVE_REPO_KEY), + }); + this.applyRepository(picked?.repo ?? null); + if (picked) { + void this.workspaceState.update(ACTIVE_REPO_KEY, picked.rootPath); } - if (!repo) { - repo = this.api.repositories[0] ?? null; + } + + /** 应用活跃仓库:重订状态订阅并按序 fire(先切换事件同步 rebind,后变更事件驱动防抖刷新)。 */ + private applyRepository(repo: Repository | null): void { + if (repo === this._repo) { + return; } - if (repo !== this._repo) { - this.repoSub?.dispose(); - this.repoSub = undefined; - this._repo = repo; - if (repo) { - this.repoSub = repo.state.onDidChange(() => this._onDidChange.fire()); - } - this._onDidChange.fire(); + const previousRoot = this.repoRoot; + this.repoSub?.dispose(); + this.repoSub = undefined; + this._repo = repo; + if (repo) { + this.repoSub = repo.state.onDidChange(() => this._onDidChange.fire()); + } + this._onDidChangeRepository.fire({ previousRoot, root: this.repoRoot }); + this._onDidChange.fire(); + } + + /** 手动切换活跃仓库(仓库选择器 / 命令接缝)。未命中返回 false;命中当前仓库返回 true(幂等)。 */ + selectRepository(root: string): boolean { + const hit = this.api.repositories.find((r) => normalizeRoot(r.rootUri.fsPath) === normalizeRoot(root)); + if (!hit) { + return false; + } + if (hit === this._repo) { + return true; } + this.applyRepository(hit); + void this.workspaceState.update(ACTIVE_REPO_KEY, hit.rootUri.fsPath); + return true; + } + + /** 已发现仓库投影(QuickPick / webview 消费,不暴露内部 Repository)。 */ + listRepositories(): readonly { rootPath: string }[] { + return this.api.repositories.map((r) => ({ rootPath: r.rootUri.fsPath })); } /** 读取本地变更(已暂存 + 工作区 + 未跟踪,按相对路径去重,index 优先),映射为 ChangeItem。 */ @@ -146,5 +202,6 @@ export class GitRepositoryService implements vscode.Disposable { this.repoSub?.dispose(); this.disposables.forEach((d) => d.dispose()); this._onDidChange.dispose(); + this._onDidChangeRepository.dispose(); } } diff --git a/src/adapter/repository-selection.ts b/src/adapter/repository-selection.ts new file mode 100644 index 0000000..72151a7 --- /dev/null +++ b/src/adapter/repository-selection.ts @@ -0,0 +1,43 @@ +import * as vscode from 'vscode'; +import type { GitRepositoryService } from './git-repository-service'; + +/** + * 仓库选择命令(issue #107 多根工作区):Graph 工具栏按钮与 Command Palette 共用入口。 + * + * - 带参调用(root 路径)= 程序化切换(集成测试接缝,无 UI); + * - 无参 = QuickPick 交互(Git Graph 形态:仓库名 + 完整路径,当前项预选); + * - 单仓库时提示 no-op;零仓库静默返回(对齐全扩展「无 repo 短路」惯例)。 + */ +export function registerRepositorySelectionCommand(service: GitRepositoryService): vscode.Disposable { + return vscode.commands.registerCommand('hyperGit.selectRepository', async (root?: string) => { + const repos = service.listRepositories(); + if (repos.length === 0) { + return; + } + if (root !== undefined) { + // 程序化切换:失败给出可见反馈(仓库可能已被移除)。 + if (!service.selectRepository(root)) { + void vscode.window.showWarningMessage('Repository is no longer available'); + } + return; + } + if (repos.length === 1) { + void vscode.window.showInformationMessage('Only one Git repository in this workspace'); + return; + } + const current = service.repoRoot; + const items = repos.map((r) => ({ + label: `$(repo) ${r.rootPath.split(/[\\/]/).filter(Boolean).pop() ?? r.rootPath}`, + description: r.rootPath, + rootPath: r.rootPath, + picked: r.rootPath === current, + })); + const pick = await vscode.window.showQuickPick(items, { placeHolder: 'Select Git Repository' }); + if (!pick) { + return; + } + if (!service.selectRepository(pick.rootPath)) { + void vscode.window.showWarningMessage('Repository is no longer available'); + } + }); +} diff --git a/src/adapter/shelf.ts b/src/adapter/shelf.ts index 77302b5..9af6046 100644 --- a/src/adapter/shelf.ts +++ b/src/adapter/shelf.ts @@ -5,6 +5,8 @@ import * as vscode from 'vscode'; import type { GitRepositoryService } from './git-repository-service'; import { handleGitConflict } from './conflict-ui'; import { mdTooltip, relativeDate } from './tree/tree-tooltip'; +import { shelfRepoDirName } from '../engine/git-state/shelf-dir'; +import { logGit } from '../infra/git-console'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -42,13 +44,54 @@ export type ShelfTreeNode = ShelfNode | ShelfFileNode; * - Unshelve:读取 patch → `git apply`(静默)或 `git apply --3way`(三方合并冲突解决)。 * - Drop:删除 patch 文件。 * + * 多根仓库(issue #107):存储按仓库隔离——`shelves/./`, + * 目录随 `service.repoRoot` 动态求值(零快照零双源),切换仓库即切换 shelf 集。 + * * 与 git stash 的区别:Shelf 是命名 patch 序列(扩展存储),不占用 git stash 栈;可同时保留多个独立 shelf。 */ export class ShelfService { - private readonly shelvesDir: string; + private readonly shelvesBase: string; constructor(private readonly service: GitRepositoryService, storageDir: string) { - this.shelvesDir = path.join(storageDir, 'shelves'); + this.shelvesBase = path.join(storageDir, 'shelves'); + } + + /** 当前活跃仓库的 shelf 目录;无 repo 时 null(调用方各自短路)。 */ + private currentDir(): string | null { + const root = this.service.repoRoot; + return root ? path.join(this.shelvesBase, shelfRepoDirName(root)) : null; + } + + /** + * 旧版平铺目录(shelves/*.json 不分仓库)一次性安全迁移到当前仓库子目录: + * 仅 rename(零删除)、目标已存在即跳过(零覆盖)、失败静默可重试(已移入保留,未移入下次续迁)。 + * 多仓库历史混仓数据无法事后归因,归属当前活跃仓库(Known Limitation)。 + */ + private migrateLegacyShelves(targetDir: string): void { + try { + if (fs.existsSync(targetDir)) { + return; // 本仓库已迁移/已使用(幂等出口) + } + if (!fs.existsSync(this.shelvesBase)) { + return; + } + const legacyFiles = fs.readdirSync(this.shelvesBase).filter((f) => f.endsWith('.json')); + if (legacyFiles.length === 0) { + return; + } + fs.mkdirSync(targetDir, { recursive: true }); + for (const f of legacyFiles) { + const dest = path.join(targetDir, f); + if (fs.existsSync(dest)) { + continue; // 绝不覆盖 + } + fs.renameSync(path.join(this.shelvesBase, f), dest); + } + logGit(['shelf:migrateLegacy'], targetDir); + } catch (e) { + // 权限/占用等:已移入的保留(合法数据),未移入的留在原处下次重试,零数据损失。 + logGit(['shelf:migrateLegacy:failed'], undefined, errMsg(e)); + } } async shelve(name: string, paths: readonly string[], timestamp: string): Promise { @@ -56,13 +99,18 @@ export class ShelfService { if (!repo) { throw new Error('No Git repository found'); } + const dir = this.currentDir(); + if (!dir) { + throw new Error('No Git repository found'); + } const patch = await this.service.execGit(['diff', '--', ...paths]); if (!patch.trim()) { throw new Error('Selected files have no changes (or are untracked)'); } - fs.mkdirSync(this.shelvesDir, { recursive: true }); + this.migrateLegacyShelves(dir); + fs.mkdirSync(dir, { recursive: true }); const entry: ShelfEntry = { name, paths, timestamp, patch }; - fs.writeFileSync(path.join(this.shelvesDir, `${sanitize(name)}.json`), JSON.stringify(entry, null, 2)); + fs.writeFileSync(path.join(dir, `${sanitize(name)}.json`), JSON.stringify(entry, null, 2)); // 移除工作区改动(变更已保存在 patch) await this.service.execGit(['checkout', '--', ...paths]); } @@ -96,22 +144,31 @@ export class ShelfService { } drop(name: string): void { - const file = path.join(this.shelvesDir, `${sanitize(name)}.json`); + const dir = this.currentDir(); + if (!dir) { + return; + } + const file = path.join(dir, `${sanitize(name)}.json`); if (fs.existsSync(file)) { fs.unlinkSync(file); } } listShelves(): ShelfNode[] { - if (!fs.existsSync(this.shelvesDir)) { + const dir = this.currentDir(); + if (!dir) { + return []; + } + this.migrateLegacyShelves(dir); + if (!fs.existsSync(dir)) { return []; } return fs - .readdirSync(this.shelvesDir) + .readdirSync(dir) .filter((f) => f.endsWith('.json')) .map((f) => { try { - const entry = JSON.parse(fs.readFileSync(path.join(this.shelvesDir, f), 'utf8')) as ShelfEntry; + const entry = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as ShelfEntry; return { kind: 'shelf' as const, name: entry.name, paths: entry.paths, timestamp: entry.timestamp }; } catch { return null; @@ -121,7 +178,11 @@ export class ShelfService { } private readEntry(name: string): ShelfEntry | null { - const file = path.join(this.shelvesDir, `${sanitize(name)}.json`); + const dir = this.currentDir(); + if (!dir) { + return null; + } + const file = path.join(dir, `${sanitize(name)}.json`); if (!fs.existsSync(file)) { return null; } diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts index a32bf60..90b8d79 100644 --- a/src/adapter/tree/branches-tree.ts +++ b/src/adapter/tree/branches-tree.ts @@ -61,7 +61,7 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider private readonly disposables: vscode.Disposable[] = []; private refsCache: RawRef[] | undefined; private refsInFlight: Promise | undefined; - private readonly groupingKey: string; + private groupingKey: string; /** 「按前缀分组为文件夹树」开关(工具栏切换、按仓库持久化,默认开启)。 */ private groupingEnabled: boolean; @@ -77,6 +77,20 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider this.disposables.push(favorites.onDidChange(() => this.refresh())); } + /** + * 多根仓库切换 rebind(issue #107):换 groupingKey 并重读该仓库的分组偏好,refresh 清缓存重取。 + * 配套的 `hyperGit.branchesGrouping` context key 由 extension.ts 级联处同步(读取重绑后的 grouping)。 + */ + setRepoRoot(repoRoot: string): void { + const key = `hyperGit.branchesGrouping:${repoRoot}`; + if (key === this.groupingKey) { + return; + } + this.groupingKey = key; + this.groupingEnabled = this.workspaceState.get(this.groupingKey) ?? true; + this.refresh(); + } + refresh(): void { this.refsCache = undefined; this._onDidChange.fire(undefined); diff --git a/src/adapter/webview/commit-webview.ts b/src/adapter/webview/commit-webview.ts index 70c72e9..db5f974 100644 --- a/src/adapter/webview/commit-webview.ts +++ b/src/adapter/webview/commit-webview.ts @@ -180,6 +180,7 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider { tree: buildFileTree(files.map((f) => f.path)), conventionalEnabled: this.commit.conventionalEnabled(), busy: false, + repoRoot: this.service.repoRoot ?? '', }; this.post({ type: 'state', payload: state }); this.sendValidation(); @@ -274,11 +275,30 @@ details.advanced[open] summary { margin-bottom: 4px; }