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; }