Skip to content

feat(repos): 子模块作为独立仓库出现在仓库选择器里 - #3

Merged
KannaKuron merged 2 commits into
KannaKuron:mainfrom
sitns:feat/submodule-repos
Sep 20, 2026
Merged

KannaKuron merged 2 commits into
KannaKuron:mainfrom
sitns:feat/submodule-repos

Conversation

@sitns

@sitns sitns commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

这个 PR 做什么

git 子模块作为独立仓库出现在面板顶部的仓库选择器里——即桌面 Git 客户端的行为:点一下子模块,面板就切进那个子仓库。

git 把每个子模块记成一个独立工作树:有自己的 HEAD、索引、分支;父仓库只看到一个 gitlink 行(git status M pathgit diffSubproject commit <old> → <new>)。所以子模块本来就是一个可以绑定的仓库,缺的只是把它发现出来

依赖

⚠️ 基于 #2fix/exports-package-json)。没有那行 exports 修复,本改动在 DSH Desktop 这类打包宿主上无法生效——客户端半区根本不会被组合进 boot 图,面板打不开也就无从选择。(GitHub 会在 #2 合入后自动把本 PR 的 base 变基回 main。)

改动

  • 新增 collectSubmoduleRepos(root, depth, prefix):以 git submodule status --recursive 取登记路径(未 init 的子模块也在表内),逐层递归到 SUBMODULE_MAX_DEPTH = 2。每层必须等上一层回答,所以 submodule status 串行;但工作树校验(rev-parse --show-toplevel)走 Promise.all 并发——一个有十几个子模块的仓库否则要在每次扫描时付十几次串行 spawn。
  • 新增 scanRepos(cwd):合并「工作区自身 + 目录扫描到的子仓库 + 每一个已发现仓库的子模块」。结果按归一化路径去重、分支查询并发,并按 cwd 缓存 60 秒(面板每 2 秒轮询一次,而一次扫描要 spawn 每个检出一次)。修复前实测:首次 4.6s,缓存命中 1.9ms。
  • repos() 改为调用 scanRepos(),行内新增 label(短名);name 保留仓库相对路径,让重名检出在长列表里可区分。
  • 客户端零改动:子模块行的 kind 仍是 'nested'RepoPicker 已渲染成「子仓库」),选中后该路径作为 repoRoot 随每次请求下发,宿主 repoRootOf() 优先取它,于是所有 git 命令自然作用在子仓库上。

两处容易踩空的地方

① 子模块属于「每一个仓库」,不只是工作区本身。 多仓项目的常见形态是工作区为容器isRepo = false):它自己没有 .git,但列出的每个仓库各自有子模块。只对 isRepo === true 跑子模块发现的话,容器工作区里 eis 下那 8 个子模块一个都不会出现——真机上就踩到了这个,面板顶部一直只显示静态标签。

② 去重要让子模块行赢。 子模块的父目录未必是仓库(本 PR 测试夹具里的 libs/ 就不是),于是目录扫描也能走到同一个检出,并且先入列、名字只有末段。子模块行带仓库相对路径、信息更全,必须覆盖前者——实现是「子模块行最后入列 + Map 后写覆盖先写」,而不是「先到先得」。

测试

新增 tests/repos.test.mjs(6 项,全部用临时仓库,无外部依赖):

  1. 工作区自身 + 两个子模块都出现在 repos 里;
  2. 每个子模块报告自己的分支与短名(夹具故意让两个子模块在不同分支上);
  3. 把子模块路径当 repoRoot 调用 summary / branches,返回的是子模块的分支——证明面板确实绑定到了它而不是父仓库;
  4. 容器工作区(自身不是仓库)仍然列出其中的检出;
  5. 同一个检出被两条路径发现时只出现一行;
  6. 未初始化的子模块(有 gitlink 登记、磁盘上没有工作树)成为可选中行。

夹具需要 -c protocol.file.allow=always:Git >= 2.38 默认对子模块禁用 file:// 传输(CVE-2022-39253),而测试没有服务器可克隆。第 6 项里 git submodule status 只认索引中的 gitlink(mode 160000)——只写 .gitmodules 不够,.gitmodules 是登记表、gitlink 才是权威,测试里用 git update-index --add --cacheinfo 160000,<sha>,<path> 造出「已登记但未 checkout」这个真实状态。

$ npm test
# tests 56 / pass 56 / fail 0

package.jsontest 脚本已加入新文件。)

真机验证

DSH 0.1.5-rc.2 + DSH Desktop 2.0.13(Windows 11),一个含 8 个子模块的仓库:

修复前 修复后
工作区 = 容器目录 5 行,无子模块 13 行(5 个一级检出 + eis 的 8 个子模块)
工作区 = 仓库本身 9 行 9 行(不变)

每个子模块带自己的分支(父仓库 dev,子模块分别 dev / master),点选后暂存、提交、历史、分支全部作用于该子仓库。

未改 CHANGELOG.md

按仓库「变更记录纪律」条目随版本提交,留给维护者发版时补,以免与 npm version 流程冲突。若这个功能方向可以接受、但实现方式想调整,欢迎直接说,我按你的意见改。

`@deepseek-ai/dsh-client-modules` 的宿主半区在 `locatePkgJson()` 里定位 loader
条目的包清单。Loader 未提供内部 `resolveSync` 时(DSH Desktop 这类打包宿主走的就是
这条回退分支)它调用:

    createRequire(baseUrl).resolve('<pkg>/package.json')

本包的 `exports` 只导出 `.` 与 `./client`,该解析抛
`ERR_PACKAGE_PATH_NOT_EXPORTED` → `locatePkgJson` 返回 `undefined` →
`resolveMeta()` 返回 `null` → `processOne()` 把这行当作「未声明 dsh.client」
**静默跳过**(不抛错、不告警)。后果:

- 宿主半区照常激活(`inject = ['webServer']` 满足),启动审计
  `assertEntriesActivated` 也通过,宿主日志无任何相关记录;
- `/plugins/?id=dsh-ide-git&rev=…` 返回 404,客户端半区从未下发,
  浏览器控制台一片干净;
- better-sidebar 的 `+` 新建标签页与原生右侧栏 Guide 页都看不到入口。

对照同 profile 的其它插件:`dsh-scratchpad`、`dsh-better-sidebar` 的 `exports`
都带有 `"./package.json": "./package.json"`,所以不受影响。

复现(用 DSH 自带的组合器真实代码跑 `resolveMeta`,`ctx.loader.internal` 为
undefined 即回退分支):

    no-internal | dsh-ide-git        => NULL (silently skipped)
    no-internal | dsh-scratchpad     => OK
    no-internal | dsh-better-sidebar => OK

补上该导出后两条件码路径均正常,`clientPath` 解析到 `src/client.js`。

环境:DSH 0.1.5-rc.2(DSH Desktop 2.0.13,Windows 11)、宿主 Node v24.18.1、
dsh-ide-git 0.5.5、npm 安装。

Refs: KannaKuron#1
git 把每个子模块记成一个独立工作树:它有自己的 HEAD、索引与分支,父仓库只看到一个
gitlink 行(`git status` 里的 ` M path`,`git diff` 里的 `Subproject commit`)。
SourceTree 等桌面客户端据此把子模块当独立仓库打开——点一下子模块即切进那个仓库。
本插件此前做不到:`repos()` 只用 `rev-parse --show-toplevel` 找当前仓库,再用一次
限深 2 层的目录扫描找子仓库,而这两条都不会展开一个仓库已登记的子模块。

## 改动

- 新增 `collectSubmoduleRepos(root, depth, prefix)`:以 `git submodule status
  --recursive` 取登记路径(未 init 的子模块也在表内),逐层递归到
  `SUBMODULE_MAX_DEPTH = 2`。每层都要等上一层回答,所以 `submodule status` 是串行的;
  但工作树校验(`rev-parse --show-toplevel`)走 `Promise.all` 并发——一个有十几个
  子模块的仓库否则要在每次扫描时付十几次串行 spawn。
- 新增 `scanRepos(cwd)`:合并「工作区自身 + 目录扫描到的子仓库 + **每一个已发现仓库的
  子模块**」。结果按 `pathIdentity()` 归一化去重(git 输出正斜杠、`path.join` 在
  Windows 上输出反斜杠,用 `===` 比较会漏掉同一次检出),分支查询并发,并按 `cwd`
  缓存 60 秒(面板每 2 秒轮询一次,而一次扫描要 spawn 每个检出一次)。
- `repos()` 改为 `scanRepos()`,行内新增 `label`(短名);`name` 保留仓库相对路径,
  让重名的检出在长列表里可区分。
- 子模块行的 `kind` 仍是 `'nested'`,因此**客户端零改动**:`RepoPicker` 已把它渲染成
  「子仓库」,而选中后该路径作为 `repoRoot` 随每次请求下发,宿主 `repoRootOf()` 优先取它,
  于是所有 git 命令自然作用在子仓库上。

两处容易踩空的地方,都在测试里钉住了:

- **子模块属于「每一个仓库」,不只是工作区本身**:多仓项目的常见形态是工作区为容器
  (`isRepo = false`),此时工作区自己没有子模块,但它列出的每个仓库各自有。只对
  `isRepo === true` 跑子模块发现的话,容器工作区里 `eis` 下那 8 个子模块一个都不会出现。
- **去重要让子模块行赢**:子模块的父目录未必是仓库(本仓库测试夹具里的 `libs/` 就不是),
  于是目录扫描也能走到同一个检出,并且先入列、名字只有末段。子模块行带仓库相对路径,
  信息更全,必须覆盖前者——实现上是「子模块行最后入列 + `Map` 后写覆盖先写」。

## 测试

新增 `tests/repos.test.mjs`(5 项,全部用临时仓库,无外部依赖):

- 工作区自身 + 两个子模块都出现在 `repos` 里;
- 每个子模块报告**自己的**分支与短名(夹具故意让两个子模块在不同分支上);
- 把子模块路径当 `repoRoot` 调用 `summary` / `branches`,返回的是子模块的分支,
  证明面板确实绑定到了它而不是父仓库;
- 容器工作区(自身不是仓库)仍然列出其中的检出;
- 同一个检出被两条路径发现时只出现一行;
- 未初始化的子模块(有 gitlink 登记、磁盘上没有工作树)**不**成为可选中行。

夹具需要 `-c protocol.file.allow=always`:Git >= 2.38 默认对子模块禁用 `file://`
传输(CVE-2022-39253),而测试没有服务器可克隆。

```
$ npm test
# tests 56 / pass 56 / fail 0
```

真机验证(DSH 0.1.5-rc.2 + DSH Desktop 2.0.13,Windows):一个含 8 个子模块的仓库
在面板顶部仓库选择器里从 1 行变成 9 行,每个子模块带自己的分支(父仓库 `dev`,
子模块分别 `dev` / `master`),点选后暂存、提交、历史、分支全部作用于该子仓库。

本次未改 `CHANGELOG.md`:按仓库「变更记录纪律」,条目随版本提交,留给维护者发版时补,
以免与 `npm version` 的流程冲突。

Refs: KannaKuron#1
@KannaKuron

Copy link
Copy Markdown
Owner

已合并并随 v0.6.0 发布(npm 已上线、npmmirror 已同步),感谢这份高质量的贡献 🙏

合并前在本机(Windows + git 2.55)复核:全量 56/56 绿,6 项新夹具测试全部通过;客户端确实零改动(RepoPicker 对 kind: 'nested' 的既有渲染直接可用);isRepo 语义与 WRITE_METHODS 纪律均未受影响。CHANGELOG 条目见 v0.6.0,其中也写明了 60 秒扫描缓存这一行为变化。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants