# @young1lin/dsh-ui-gitworkbench

🌏 [中文](./README.md) · [English](./README_EN.md)

[dsh（DeepSeek Harness）](https://github.com/deepseek-ai/deepseek-harness) 的树外 Web UI 插件：给 dsh 的 Web 界面装一个 Git 工作台，不改动 dsh 本体。

每个会话的头部都有一枚状态卡，显示当前分支、领先/落后和增删计数。点开它，右侧滑出一张工作台面板，当前 worktree 的改动一览无余：

- **变更**：可折叠的文件树，配完整上下文的左右并排 diff——双列行号、词级高亮、Shiki 语法着色，右栏可直接 Edit；二进制文件按字节嗅探，是图片就直接显示（删除的文件显示 HEAD 那份）；树顶可打关键字过滤文件列表（多词与关系、智能大小写），文件行悬浮可一键撤回到上次提交（IDEA 的 Rollback，弹窗先说清后果）；diff 头部常驻当前变更块与 `current / total`，Unstaged 可 Stage / Revert（进入 Edit 也保留），Staged 可 Unstage 当前块或整个文件；Ctrl/Cmd+F 两列查找——未武装时搜左右两列（先左后右、跨列步进），武装后查找条只压在工作树列上方；
- **文件**：仓库目录树与可编辑文件查看器，支持搜索、图片预览、CodeMirror 编辑和 blame 行信息；
- **历史**：提交列表 / 文件树 / diff 三栏并排，滚动到底自动翻页；行内带作者、悬浮卡带精确时间；diff 内 Ctrl/Cmd+F 查找（Enter / Shift+Enter 上下一个、`当前 / 总数` 计数、命中着色），图片显示该提交的那份（删除的显示父提交那份）；IDEA 式过滤（`user:` / `path:` / `after:` 输入语法，或作者 / 日期 / 路径分区漏斗弹层），条件编译进 `git log`、全历史匹配、车道图常驻，另有「全部分支」；
- **对比**：任选两个分支互相比较，diff 内同样可查找，图片显示 head 那份（删除的显示 base 那份）；
- **提交与同步**：树上勾选文件就是真实的 `git add` / `git restore --staged`，配合提交框和 fetch / pull / push 同步条，一次提交加推送全程不用离开面板；头部另有分支切换器（仅主工作树）——当前分支打点、被其他工作树占用的置灰并注明去向、远端独有分支一键签出并跟踪，本地改动带得动就随行、带不动 git 拒绝并归类提示，绝不 force；
- **外观**：七套主题族各带亮暗，默认跟随系统；支持虚化背景图和自定义 CSS，按「项目 / 全局」两个作用域保存，项目优先。

另带 **worktree 仿真**：模型在会话里调用 `worktree_enter` / `worktree_exit` / `worktree_status` 三个工具，即可在 `.agents/worktrees/<name>` 下建立或退出隔离 worktree，并把会话绑定过去。**子代理会话不写自己的绑定，而是沿谱系借用最近绑定祖先的 worktree**——standing 提示、芯片与 `worktree_status` 对无自有绑定的会话统一解析「有效绑定」，外层退出后子树自动失去借用。绑定后状态卡点亮绑定标记，面板头部出现 worktree 切换器（按分支列出仓库全部 worktree），统计随之切换。

<div align="center">
  <video src="https://github.com/user-attachments/assets/c6a73c7b-bf69-4b97-80a2-9175bc293d7d" muted autoplay loop playsinline controls width="100%"></video>
  <sub>演示（2 分 24 秒）：状态卡 → 变更页（勾选暂存 / 逐文件 diff / 提交）→ 外观（明暗 + 七套配色 + 背景图）→ 历史页（提交图 / worktree 切换）</sub>
</div>

> 这份 README 同时是**交接文档**：插件是什么、怎么写的、踩过哪些坑、怎么继续改，全部记录在案。接手开发前请先读「§6 踩坑实录」——那里是真实调试换来的关键事实。

## 0. 安装

**前置**：DSH 已装好（`dsh web` 能正常运行），Node.js ≥ 20，pnpm ≥ 10。

**推荐：官方插件通道，一条命令。**

```sh
dsh plugin --profile web add @young1lin/dsh-ui-gitworkbench
```

装完**重启 DSH，再硬刷新浏览器**（Ctrl/Cmd + Shift + R）。包内声明了 `dsh.bundle.patch`，CLI 会自动把宿主半注册进 profile 的 `dsh.profile.bundles`，下次启动即挂载，**不需要**手写任何 `cordis.patch.yml` 挂载行。机器上没有 `dsh` 命令时，用 npx 直接跑：

```sh
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add @young1lin/dsh-ui-gitworkbench
```

**已安装的升级**用 `update`，不要重复 `add`：

```sh
dsh plugin --profile web update @young1lin/dsh-ui-gitworkbench
```

`dsh plugin` 是 pnpm 的薄转发层：重复 `add` 对已装包不报错，但会把依赖重装成最新版并**覆盖 `link:` 软链安装**（从源码开发的机器会突然「回到」npm 版）；`update` 按安装态对账，新版新增的 `dsh.bundle` 声明也会被自动激活进层栈。升级后同样重启 DSH。

<details>
<summary><b>备选：一键脚本</b>（同样走官方通道，多处理两件小事）</summary>

```sh
# macOS / Linux（Windows 装了 Git Bash 或 WSL 也可）
curl -fsSL https://raw.githubusercontent.com/young1lin/dsh-ui-gitworkbench/main/scripts/install.sh | bash
```

```powershell
# Windows（PowerShell 5.1+ / pwsh）
irm https://raw.githubusercontent.com/young1lin/dsh-ui-gitworkbench/main/scripts/install.ps1 | iex
```

脚本在安装命令之外多做两件事：预写 pnpm 11 的 `minimumReleaseAgeExclude`，让刚发布不足 24 小时的版本也能立即安装；幂等清理旧版手动挂载行，避免宿主半挂载两次（页面上出现两个状态卡）。支持指定版本、装完 `pm2 restart dsh-web`、`--dry-run` 试跑等参数，见脚本头部注释。

</details>

<details>
<summary><b>从源码开发</b></summary>

`dsh plugin --profile web add <本仓库路径>` 把源码装进 profile；改完客户端半跑 `npx tsdown` 再刷新浏览器即可生效（宿主半改动需重启 dsh web）。详见 §5。从 `link:` 源码依赖切回 npm 版时，记得移除 `cordis.patch.yml` 里的手动挂载行（安装脚本会自动处理）。

</details>

### 发布（维护者）

首次发布与后续发布走不同链路：

- **首次**（包还不存在于 npm，Trusted Publishing 尚无处配置）：本机 `npm login` 后 `npm publish`（scope 包的 `publishConfig.access` 已设 public）。发布后到 npmjs.com → 包 Settings → Trusted publishing 添加 GitHub Actions 发布器：user `young1lin`、repository `dsh-ui-gitworkbench`、workflow 填 `publish.yml`（不带路径前缀）、Environment 留空、勾选允许 `npm publish`。手工发布不经 CI 里那道机器路径门禁（见 publish.yml 的 grep 步骤），发布前可自行扫一眼 `lib/*.js` 确认没有本机绝对路径混入。
- **后续**：`npm version patch`（或 minor/major）→ `git push` → `git push --tags`。tag `vX.Y.Z` 触发 `.github/workflows/publish.yml`：CI 全量检查 → tag 与 package.json 版本一致性校验 → OIDC Trusted Publishing 自动 `npm publish`（provenance 自动生成，全程无 npm token）。不要手动补推已由人工发布过的版本的 tag（如首次的 v0.1.0），registry 会拒绝同版本重发。

**发布产物不带 sourcemap。** `lib/client.js.map` 解包 3.1MB、gzip 416kB，占了整包下载的 46%；排掉后 tarball 从 914.6kB 降到 498.0kB。两处配合才干净：`prepack` 走 `bundle:publish`（`tsdown --no-sourcemap`，连 `//# sourceMappingURL` 注释一并不产出——只删文件不删注释的话，dsh 的 `/plugins/<id>/client.js.map` 路由会给每个使用者一个 404），`files` 里的 `!lib/*.map` 再兜一道，防止上一次 dev 构建遗留的 map 被 `clean: false` 留在 `lib/` 里蹭进包。

**副作用记一笔**：`npm publish` 和 `npm pack`（含 `--dry-run`）都会触发 `prepack`，所以跑完之后本机 `lib/client.js` 是不带 sourcemap 注释的那份，浏览器里断点看到的是打包后的代码。继续开发前跑一次 `pnpm exec tsdown` 就回来了。

---

## 1. 当前状态（已验证）

| 能力 | 状态 | 验证方式 |
|---|---|---|
| 宿主 `gitWorkbench/stats` RPC 返回真实统计 | ✅ | `curl -X POST /api/gitWorkbench/stats` 返回 `{ok:true, value:{branch, files[], diff}}` |
| 客户端 bundle 被 shell 加载（boot 清单） | ✅ | `window.__DSH_BOOT__.entries` 含 `@young1lin/dsh-ui-gitworkbench` |
| 浏览器→宿主 RPC 通 | ✅ | 页面内 `fetch('/api/gitWorkbench/stats', ...)` 返回 200 |
| 面板 diff 完整（不丢文件） | ✅ | 换用 subprocess pipe 后，`diff --git` 计数 = 文件数 |
| 状态卡在 git 仓库会话常驻显示（分支/↑↓/计数），仅非 git 目录或 git 失败时隐藏 | ✅ | 干净树也显示分支名（状态卡即会话的环境信息位）；绑定徽标见 §9 |
| agent 工具 `worktree_enter/exit/status`（模型可调） | ✅ | 真实会话冒烟 `scripts/llm_smoke.py`：模型调 enter → `.agents/worktrees/llm-smoke` 出现；exit(remove) → 消失 |
| 宿主 worktree RPC（enter/exit/status/sessionWorktree）+ 绑定文件 | ✅ | `python scripts/probe_worktree.py`：scratch 仓库断言 + 真仓库冒烟 + 再进入分支复用，ALL PASS |
| 状态卡绑定标记（树形图标；徽标文字与分支重名时省略）+ 头部 worktree 选择器 | ✅ | `python scripts/verify_worktree_ui.py`：6 步 UI 探针（绑定标记、头部路径、选择器切换、折叠/
| 头部分支切换（主工作树限定 / 占用置灰 / 远端签出跟踪 / 拒绝与随行） | ✅ | `python scripts/verify_branch_switch.py`：12 步实机探针（fixture 仓库，HEAD 与改动全程可还原） |选中回归） |
| 历史过滤（作者 / 日期 / 路径下推 `git log`、「全部分支」、日历与三态路径树） | ✅ | `python scripts/verify_history_feature.py`：11 步 UI + host 探针全过（中文作者、All-branches、日历选界、目录吸收文件勾选、诚实空态） |
| 单文件撤回（Rollback）与文件列表关键字过滤 | ✅ | 对 live app 实测：撤回弹窗措辞随 host 实时推导的后果变化、取消不动手、执行后 fixture 回静息态；过滤框多词 AND、忽略折叠、根勾选只动可见行 |
| 变更块导航与 Staged 恢复出口 | ✅ | `tests/edit-hunk-actions.test.ts` + scratch fixture live probe：Staged 常驻 Unstage file，多块另有 Unstage hunk；操作后回到 Unstaged，页面无错误 |
| 客户端半被类型检查 | ✅ | `tsconfig.client.json` 进了 `bundle`/`typecheck`；曾故意写坏一处，确认报 `TS2322` |
| 主题 7 族 × 亮暗 + 跟随系统明暗 | ✅ | `tests/theme-palettes.test.ts` 把 `themes.ts` 与 `.module.css` 互扣（两个方向都验过会红）；`lib/client.js` 含全部 14 套调色板 |
| 背景图 / 自定义 CSS 的项目+全局存储 | ✅ | 对**构建产物** `lib/index.js` 跑 styleGet/styleSet 全流程（临时 HOME，18/18 PASS）：读写、项目优先、越界钳制、恶意 image 拒绝、清空删记录、非仓库拒绝、两作用域并发写不互相覆盖 |
| Ctrl/Cmd+F 查找面板穿抽屉的控件，并报 `当前 / 总数` | ✅ | `python scripts/verify_search_panel.py`：24 项实机检查（条随调色板重绘、控件同高同圆角、命中底色非库自带、窄窗格回流、计数随 Enter 前进） |
| diff 窗格里的图片（变更 / 历史 / 对比）与统一 diff 的 Ctrl+F | ✅ | `python scripts/verify_pane_image_find.py`：18 项实机检查，scratch worktree `imghist`（工作区改动 / 未跟踪 / 该提交 / 父提交 / 对比 head 各一张图；Ctrl+F 开条、计数、Enter 逐个走完 22 个命中、下折时滚动、绕回、Esc 关闭并还焦点） |
| 历史过滤框按键不再随已加载行数变贵 | ✅ | `python scripts/verify_history_filter_perf.py`（CDP CPU profile + 帧卡顿计数）：同一会话 116 行已加载、12 个按键，脚本时间 449ms → 150ms，`formatCommitDate` 从 profile 首位消失 |
| 抽屉视觉词汇表单一（圆角 / 字号 / 控件高度 / 悬停 / 选中） | ✅ | `tests/drawer-chrome.test.ts` 逐条声明扫描全表，六个变异全红；`python scripts/verify_vocabulary.py` 实机复核（18 个筛选控件同高、17 处小字同号、树行圆角与选中 chip） |
| 行号槽不再把代码压在底下 | ✅ | `python scripts/verify_gutter.py`：窗格拖窄后横向滚动 400px，63 行钻到槽下，槽有自身底色且向左溢出；可编辑轨条仍在槽之上 |

**已知边界**：状态卡挂在 `conversation.session.header.actions` 插槽，只有**打开了会话（会话头渲染）**时才挂载。无头自动化里若没真正打开会话，状态卡不会出现——这是预期行为，手动在 UI 里开一个会话即可看到。

---

## 2. 架构（一句话 + 详情）

> **宿主半**：一个 `TypertRemoteService`，跑 git 算统计 + worktree 增删与「会话→worktree」绑定，经 Typert gateway 自动发现；同一服务再以 `defineTool` 注册三个 agent 工具。**客户端半**：一个 React 面板，注册进会话头插槽，通过 `connection.rpc` 向宿主要数据（统计 + 会话绑定）。

### 2.1 宿主半（`src/index.ts`）

```ts
class GitWorkbenchService extends TypertRemoteService {
  static inject = ['subprocess']          // 等 subprocess 服务就绪才激活
  constructor(ctx) { super(ctx, 'gitWorkbench') }   // 注册为 ctx.gitWorkbench，命名空间 = 'gitWorkbench'
  @Remote('stats')                        // endpoint = gitWorkbench/stats
  async stats(worktreePath, signal) { ... 用 ctx.subprocess.spawn 跑 git ... }
}
export default GitWorkbenchService
```

- **Typert gateway 通过"源码标记反射"自动发现**这个方法（读 `@Remote` 装饰器在原型上打的 marker）——**不需要生成 descriptor、不需要改 monorepo 任何文件**。这是树外插件最干净的 RPC 暴露方式。
- 浏览器侧调用：`ctx.connection.rpc.call('/api', 'gitWorkbench/stats', { args: { worktreePath } }, signal)` → 返回 `{ok, value} | {ok:false, error}`。
- **取数用 `ctx.subprocess.spawn({argv:['git',...], cwd, stdio:{stdout:'pipe'}})`，自己累加 stdout 流**。见踩坑 §6.3。

### 2.2 客户端半（`src/client/`）

```ts
// src/client/index.ts
export const inject = ['sessions', 'slots', 'connection']
export function apply(ctx) {
  const connection = ctx.connection
  ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register(
    { name: 'conversation.session.header.actions', id: 'git-workbench', order: 30,
      inject: () => ({ fetchStats: async (worktreePath, signal) => {
        const r = await connection.rpc.call('/api', 'gitWorkbench/stats', worktreePath ? {args:{worktreePath}} : {args:{}}, signal)
        return r.ok ? r.value : null
      }}) },
    GitWorkbenchPanel,
  ))
}
```

- **插槽系统**：`ctx.slots.inject(key, cb)` 会在 `key` 插槽被声明后执行 `cb`；`cb` 里 `ctx.slots.register({name,id,order,inject}, Component)` 注册组件。可复用已有插槽（如本插件的 `conversation.session.header.actions`），也可用 `declare module '@deepseek-ai/dsh-client-ui-slots'` 声明合并新增插槽。
- **组件 props**：`PropsRuntime<'conversation.session.header.actions'>` 提供 `sessionId`、`useSessions` 等；`inject` 工厂返回的对象（如 `fetchStats`）会作为 props 注入组件。**业务回调从 apply 作用域经 inject 工厂过到组件，绝不用全局 ctx**。
- **拿 worktree 路径**：`useSessions(state => state.byId[sessionId]?.cwd)`——会话摘要自带 `cwd`。
- **数据刷新**：挂载时拉一次 + 面板打开时轮询（空闲 15s、agent 运行中加密到 3s——运行中的会话正在改文件，等满 15s 看到的就是旧闻）+ 手动刷新按钮。**面板关着时另有一条便宜的绑定探针**（见 §6.0d）：只在 agent 运行中开表，走不 spawn git 的 `sessionWorktree`，发现绑定变了才补一次 `worktreeStatus`。

### 2.3 组件与样式（`GitWorkbenchPanel.tsx` + 功能组件 + `styles/*.css`）

- **外壳**：面板是一张四边留白的卡片（`--gs-inset`，14px 圆角、投影），最大化按钮切到满屏。**三条边可拖**：卡片左缘（`MIN_DRAWER_WIDTH`）、提交列表与文件树之间、文件树与 diff 之间。三处共用 `useHorizontalDrag`（pointer capture + `pointercancel`）。窗格上界由 `applyPane` 现场量出来算：`面板宽 - 邻窗格宽 - MIN_DIFF_WIDTH`，diff 是唯一不能折行的窗格，所以它的下限是硬的。宽度与主题存 localStorage。
- **布局**：变更页 = **文件树 + 逐文件 diff** 两栏；历史页 = **提交列表 + 文件树 + diff** 三栏并列（GitHub Desktop / JetBrains git log 的做法），各自独立滚动，因此没有可折叠的东西要解释。翻页是滚动哨兵（IntersectionObserver），不是按钮。
- **逐块操作**：`DiffViews.tsx` 用完整上下文 side rows 把连续增删行归成块；点击代码块或按 F7 / Shift+F7 更新显式 current block，头部固定按钮始终作用于这一个块。Unstaged 提供 Stage / Revert，Staged 提供 Unstage hunk / file；整文件 Unstage 在点击时才收集所有变化行，并沿用 `diffSha` 过期检查。Edit 模式改用 working-tree 真实行号计算 CodeMirror 的 dense 滚动位置，dirty buffer 只禁用 Git 区块操作，不隐藏按钮。
- diff 渲染：`renderDiff(segment)` 把统一 diff 逐行分类，渲染成 `[老行号][新行号][+/-槽][代码]` 的 flex 行；行号从 `@@ -a,b +c,d @@` 解析并随行递增。
- **样式装载**：`GitWorkbenchPanel.module.css` 只是一张清单，按功能 `@import` `styles/*.css`；构建在 CSS Modules 作用域化之前内联它们，运行时仍是一张类名表和一个 `<style>`，不是十次网络或十个 style 标签。
- **配色**：面板自带调色板，不走 dsh 主题 token——diff 需要 增/删/词级/语法 四组颜色，dsh 没有定义。**所有颜色都过 `--gs-*` token**，字面色只出现在 `.overlay[data-gs-theme='<family>-<mode>']` 的调色板块里；换主题＝换一组 token，别的什么都不动。**只有状态卡（在 dsh 原生 chrome 里）保留 `--dsw-*` token**。
  - 主题族与解析逻辑在 `src/client/themes.ts`（不 import CSS/React，因此可被测试直接加载）：GitHub / IntelliJ IDEA / VS Code / One / Solarized / Nord / Cyberpunk，各带亮暗两套。
  - 明暗默认 `system` = 跟随操作系统（`matchMedia('(prefers-color-scheme: dark)')`，挂载期间持续跟随）；显式选亮/暗则完全覆盖。
  - **明暗三个按钮的色块是写死的**白 / 近黑 / 对角各半，不取调色板：那三个按钮命名的就是颜色本身，暗色主题下把「亮色」画成深灰等于告诉用户反话。这是全文件唯一允许出现字面色的第二处。
  - `tests/theme-palettes.test.ts` 把 `themes.ts` 的族列表和 `.module.css` 的调色板选择器互相扣死：少一套调色板会让面板一个 `--gs-*` 都没有、整块退回浏览器默认色而不报错，所以这个不变量必须由测试守。

### 2.3b 自定义样式：背景图 + 自定义 CSS（`src/style-store.ts` + 宿主 `styleGet/styleSet`）

- **两个作用域**：`project`（按仓库根 key）与 `global`。**背景图整条取项目的**——虚化度/遮罩是为某一张图调的，换一张图就不成立，所以不做逐字段合并。**自定义 CSS 两边都生效**，global 在前、project 在后，靠 CSS 层叠顺序让项目覆盖全局；这比"整块覆盖"有用：全局定字号、项目改强调色。解析逻辑在 `themes.ts` 的 `effectiveBackground` / `effectiveCss`，`tests/style-resolve.test.ts` 守着。
- **存在宿主而不是 localStorage**：项目设置该跟着项目走（换浏览器、清 origin 都不该丢），而且一张背景图远超 origin 配额。文件是 `~/.dsh/gitworkbench-style.json`，原子写复用 `src/atomic-json.ts`（tmp+rename + Windows EPERM 退避），两个作用域并发写经 `withStyle` promise 队列串行化。
- **图片先在浏览器里降采样**（`createImageBitmap` → canvas → JPEG，长边 ≤2560，q0.82）再存。手机照片 4-6MB，虚化之后那些细节一点都留不下，没必要每次开面板都拖着走。
- **`image` 只接受 base64 `data:` URL**（`style-store.ts` 的 `IMAGE_PATTERN`）。客户端要把它插进 `url("…")`，而 base64 字母表里没有引号、括号、反斜杠、分号，所以存进去的值不可能闭合函数再追加规则。`https://`、`data:image/svg+xml`、`data:text/html` 一律拒绝，`tests/style-store.test.ts` 逐条验过。
- **背景怎么画**：`.drawer[data-gs-bg]::before` 铺图 + `filter: blur()`（`transform: scale(1.12)` 是因为模糊会采样到盒子外，不放大边缘会透明）。同时 `--gs-surface` / `--gs-surface-2` 从实色切成 `color-mix(… var(--gs-veil), transparent)`，各窗格因此透出底图；**弹出层（主题菜单、分支选择器）故意保持实色**，压在虚化照片上的菜单没法读。没设背景图时这两个 token 就等于 `--gs-bg` / `--gs-panel`，即与之前逐像素一致。
- **用户 CSS 的抓手是 `data-gs-part`**：`overlay` / `card` / `header` / `tabs` / `commits` / `tree` / `diff`。CSS Modules 的类名每次构建都换 hash，从外面根本选不中，所以必须有一组稳定属性。常见写法就是覆盖 token：`[data-gs-part="card"] { --gs-accent: #ff0066; }`。

### 2.4 worktree 仿真（`src/worktree.ts` 纯逻辑 + `src/index.ts` 里的 RPC/工具）

- **宿主 RPC**（同一 `GitWorkbenchService` 上多挂 4 个 `@Remote`，参数照 §6.8 裸标识符、signal 最后）：
  - `worktreeEnter(sessionId, repoPath, name, branchName, signal)`——`repoRootOf` 解析仓库根；在 `<repoRoot>/.agents/worktrees/<name>` 创建（或复用）worktree、**分支 = branchName ?? 名字**（都不加强制前缀），写绑定；返回 `{ok, worktreePath, branch, hint}`，hint 教模型怎么用相对路径（会话 cwd 不可变）。`branchName` 是给斜杠分支留的口子：`feature/foo` 是合法 ref、却是 Windows 目录名拼不出的拼写，名字兼任分支时这类最通行的分支永远建不出来。它的校验按分支的规矩走（`isRefName` 加上 git 自己也会拒的 `.lock` 结尾、首尾点、`head` 大小写碰撞），**非法直接拒绝、绝不静默换名**——目录标签可以随机生成，分支名有语义；且只在全新创建时生效。复用判定走 **realpath**：目标目录已是注册 worktree（别的工具建的、或经 Junction 映射进来的，git 登记的是另一种拼写）→ 直接绑定并保留**它自己的分支**（显式传了不一致的 branchName 时 hint 注明未采用），不再 `worktree add`。
  - `worktreeExit(sessionId, remove, signal)`——解绑；`remove:true` 且树干净才 `git worktree remove`，脏树拒绝。
  - `worktreeStatus(sessionId, repoPath, signal)`——**有效绑定**（自有优先，否则沿谱系借最近绑定祖先；`bindingInherited` 标明是否借来）+ 仓库全部 worktree 列表。
  - `sessionWorktree(sessionId, signal)`——`{worktreePath, name, inherited}`，只读绑定 JSON、零 git spawn；无自有绑定时借最近绑定祖先（`inherited:true`），连祖先也无绑定才是双 `null`。客户端轮询已改用 `worktreeStatus`（绑定+列表一次拿全），这个 RPC 保留作轻量单查。
- **绑定持久化** `~/.dsh/gitworkbench-worktree-bindings.json`（`{v:1, bindings:{<sessionId>:{repoRoot,worktreePath,name,enteredAt}}}`）。写法是**先写 `.tmp` 再 rename**（崩溃不留半截文件）；Windows 上 rename 可能 EPERM → 25/50/100/200/400ms 退避重试；所有 load→save 段落经 promise 队列互斥（`withBindings`），并发 enter/exit 不会互相覆盖。
- **agent 工具**：同一份逻辑用 `ctx.tools.register(defineTool({...}))` 注册成 `worktree_enter/exit/status`，sessionId/cwd 取自 `exec.agent?.session`（**不接受**模型传参）——注册要点见 §6.10，schema 限制见 §6.11。
- **客户端跟随**：`GitWorkbenchPanel` 每轮拉 stats 的同时拉 `worktreeStatus(sessionId, cwd)`（绑定 + 仓库全部 worktree 一次拿到，agent 在 dsh 外面建的 worktree 也会跟进列表）；有绑定 → 状态卡亮出绑定标记（树形图标；分支与徽标文字重名时省略后者）、stats 改传绑定的 worktree 绝对路径；面板头部的 worktree 选择器按分支列出所有源，**只切显示对象、不动绑定**。树的展开状态跨切换、跨轮询保留；选中在切换源时**有意重置**——旧 worktree 的路径不能漏进新树的选中（§6.0c）。

### 2.5 写操作：暂存 / 提交 / 同步 / 撤回（`src/git-ops.ts` + `src/discard-ops.ts` + 宿主 9 个 `@Remote`）

- **勾选就是 git 调用**：勾一个文件＝`git add -- <path>`，取消＝`git restore --staged -- <path>`，立即生效。argv 全部数组构造（无 shell，引号不是攻击面），路径一律放 `--` 之后并拒绝前导 `-`（文件可以合法叫 `-f`，位置参数传进去就成了选项）；全库没有 `--force`/`reset --hard`/`clean` 任何拼写——丢提交类操作需要的是专门的确认设计，不是碰巧排在旁边的按钮。
- **点击即显、不丢点击**：勾选走乐观更新 + 队列（`stage-tree.ts` 的 `nextBatch` 按动作聚批，一次 drain 只发一个 git 调用——宿主一次调用 ~300ms，等它返回再画勾就是用户投诉的「超级卡」），120ms 内连点两下都会入队生效；轮询回包经 `settledTicks` 对账后落定。
- **提交**：`commit(worktreePath, message, amend, signal)`——消息整段作一个 argv 元素传 `-m`（多行 body 是常态，拆分才是风险），绝不 `-a`：面板有自己的暂存区，全量扫进去等于让分区变成摆设。
- **同步**：`syncStatus`（branch/upstream/ahead/behind + hasRemote，读 `git status` 而非 `rev-list --count`——「没配 upstream」和「与 upstream 齐平」的计数都是 0，只有前者决定 push 要不要 `--set-upstream`）、`fetch --prune`（远端删掉的分支别再算作待拉取）、`pull --ff-only/--rebase/--no-rebase`（模式永远显式：按钮写什么就跑什么，不读用户的 pull.rebase 配置）、`push`（绝不 force；无 upstream 时 `--set-upstream origin <branch>`；被拒归类为 `diverged`，答案是先 pull 而不是覆盖别人的工作）。
- **单文件撤回（IDEA 的 Rollback）**：`discardPlan` / `discardFile`，计划推导在 `src/discard-ops.ts`（纯函数）。语义与 IDEA 一致：**不问暂存与否**，索引与工作区一起回退——改过的还原、未提交过的删除、误删的找回、改名撤销；目录不提供这个手势。计划由 host 用**全树** `git status` 现场推导（git 靠「一删一增」配对才认出改名，带单文件 pathspec 的 status 只看到一半，会把「撤销改名」错读成「还原一个 + 删掉另一个」，见 §6.16）；弹窗措辞来自推导出的后果（找回已删文件是纯收益，不弹窗）；执行前重推一遍并核对后果一致，文件变了就什么都不做。危险拼法禁令同上且更严：只有 `git restore` 加单个 pathspec（`--` 之后），删除走文件系统但拒绝绝对路径 / 盘符 / UNC / `..` 并按解析后路径复查在工作区内——`tests/discard-ops.test.ts` 扫描本模块可产出的每条计划守这条线。
- **失败要说人话**：`classifyFailure` 把 stderr/exit 归类为 auth / no-upstream / diverged / conflict / nothing-to-commit / dirty，原始文本随行返回——归类是提示，不替代证据。子进程环境关掉全部凭据提示（`GIT_TERMINAL_PROMPT=0`、`GCM_INTERACTIVE=never`、askpass 置空）：`stdin:'ignore'` 不会把交互提示变成错误，只会变成没人能回答的等待，而那等待挂在宿主进程里——一个过期的 token 就能挂死整个插件 30s。

### 2.6 历史过滤（宿主 `src/log-filter.ts` + `src/shortlog.ts`；客户端 `log-filter-query.ts` / `calendar.ts` / `dir-tree.ts` / `path-select.ts`）

- **条件编译成 `git log` 参数**（`log-filter.ts`：统一 `-i -E` 方言、字面量转义、`--author` 逐人、approxidate `--since`/`--until`、pathspec 放 `--` 之后），在**全部历史**上匹配后再分页——不是只筛已加载的页；过滤后的翻页仍是单次连续游走，车道图不断。裸 `yyyy-mm-dd` 由 host 展开为全天（§6.15）；`git log` 失败原样透出 stderr（exit + 尾部），不静默成「无匹配」。
- **两个入口写同一个过滤器**：输入框语法（`user:` / `path:` / `after:` / `before:` + 可删除 chips，`log-filter-query.ts`）与漏斗弹层（作者来自 `git shortlog` 且**跟随当前 ref**——名单里的人必然搜得到；自绘日历 `calendar.ts` 纯函数月格；路径树 `dir-tree.ts` 聚合 + `path-select.ts` 三态勾选：勾目录覆盖并吸收子文件，目录有半选态）。防抖 300ms、在飞请求取消、条件变化回第 0 页。
- **「全部分支」** = `--all` 哨兵（ref 不能以 `-` 开头，无歧义），ref 选择器与作者名单同步。按人搜索只匹配**作者**（git 没有「作者或提交者」并集下推，IDEA 同款），提交者完整显示在悬浮卡。

---

## 3. 文件布局

```
harness-worktree/
  package.json              dsh.client(web) + exports + 显式兼容范围的 optional peer（运行时由 profile 提供）
  .npmrc                    auto-install-peers=false（关键！见 §6.5 / §6.14）
  tsconfig.json             tsc 构建【宿主半】（stage-3 装饰器 + ambient shim）
  tsconfig.client.json      仅类型检查【客户端半】（rolldown 本身不做类型检查）
  tsdown.config.ts          客户端 closure-factory bundle + CSS Modules 构建
  vitest.config.ts          排除 .agents/**，避免 worktree 副本重复收集测试
  src/
    index.ts                GitWorkbenchService + 30 个 @Remote + worktree 三个 agent 工具
                            stats/fileDiff/fileSides/applyBlocks/writeChecked/blame/fileImage/revImage/commitStats/
                            commits/authors/repoTree/ignoredDir/compareRefs/sessionWorktree/worktreeEnter/worktreeExit/
                            worktreeStatus/styleGet/styleSet/syncStatus/stage/unstage/discardPlan/discardFile/
                            commit/fetch/pull/push/switchBranch
    atomic-json.ts          崩溃安全 JSON 写入（tmp+rename + Windows EPERM 退避）
    apply-blocks.ts         hunk patch 选择、正反向 apply 与 stale diff 防线
    blame.ts                porcelain blame 解析与路径/提交信息
    commit-cache.ts         commit hash 内容寻址 LRU
    discard-ops.ts          IDEA Rollback 的计划推导与路径防线
    fs-remove.ts            受工作区边界保护的文件删除
    git-log.ts/log-filter.ts/shortlog.ts  历史解析、过滤参数与作者名单
    git-ops.ts              写操作 argv + stderr 归类（纯函数，不 spawn；截断保首尾——关键词在头、建议在尾）
    image-sniff.ts          图片类型嗅探与读取上限
    patch-model.ts          Git patch 解析、行选择与重发射
    side-guard.ts           side diff / write 的路径与 stale-sha 校验
    style-store.ts          项目/全局外观存储
    worktree.ts             worktree 绑定、名称/分支/porcelain 纯逻辑
    write-checked.ts        编辑保存的编码、mtime/hash 与原子写校验
    types/*.d.ts            宿主/客户端 ambient shim
    client/
      index.ts              注册会话头插槽并桥接 RPC 回调
      GitWorkbenchPanel.tsx 状态卡与抽屉的状态编排；业务视图下沉到叶组件
      ChangesFileTree.tsx   变更树、过滤、勾选与提交区
      CommitHistory.tsx     历史列表、车道图、筛选与分页
      DiffViews.tsx         unified/side diff、块操作、虚拟窗口与编辑态
      WorkbenchControls.tsx 同步条、设置、来源选择器与反馈
      BranchSwitcher.tsx/branch-switch.ts  头部分支切换器：行规则（当前/占用/远端）纯函数化，仅主工作树渲染
      FileBrowser.tsx/CodeEditor.tsx/ImageView.tsx  文件页、编辑器与图片预览
      BinaryFilePane.tsx/image-source.ts  diff 窗格里的图片：按页签与状态决定读工作区、HEAD、该提交、父提交还是对比两端
      DiffFindBar.tsx/use-diff-find.ts/diff-find.ts  统一 diff 与未武装并排的 Ctrl+F：纯规则（字面量、不分大小写、5000 命中封顶；findInSides 两列先左后右）+ 停顿后扫描 + 按可视行着色；SideFindSeat/use-scroll-gutter.ts 把武装编辑器的 CodeMirror 查找面板钉在工作树列上方
      PaneDivider.tsx + *Glyph.tsx  拖拽分隔条与共享图标
      git-workbench-types.ts       面板组件/RPC 共享类型
      row-window.ts/use-row-window.ts  视口窗口纯规则与 React 桥接
      styles/*.css          按功能分片；由 GitWorkbenchPanel.module.css 构建期汇成一个 style
      *.ts                  勾选、diff、导航、缓存、过滤、主题等 React/CSS-free 纯规则
  tests/*.test.ts           单元、结构扫描、性能边界、泄漏与回归守卫（按领域与源模块对应）
  scripts/*.py              本地 live/UI/性能探针与辅助器（需真实 dsh/scratch；gitignore，不随包发布）
  cordis.patch.yml          宿主 entry 的 profile 挂载声明
  README.md / README_EN.md  中文深度交接文档 / 英文使用与维护说明
  CHANGELOG.md / CHANGELOG_EN.md  双语发布记录
```

---

## 4. 怎么构建

```bash
cd <仓库根目录>
pnpm install      # 装 tsdown/typescript/react/lightningcss/@types/node；.npmrc 关掉了 peer 自动安装
pnpm bundle       # = tsc -p tsconfig.json && tsc -p tsconfig.client.json && tsdown
pnpm typecheck    # 同样两个 tsc，不产出
pnpm test         # vitest
```

产物：
- `lib/index.js` —— 宿主半（ESM，`tsc` 产出，装饰器已转译）
- `lib/client.js` —— 客户端半（CJS closure-factory，`tsdown` 产出，CSS 已内联为 `<style>` 注入）

**只改了客户端**时，`pnpm bundle` 重建后**刷新浏览器**即可——web server 每次请求都从磁盘读 `lib/client.js`，不用重启。（别只跑 `pnpm exec tsdown`：rolldown 不做类型检查，会漏掉 `tsconfig.client.json` 才能发现的错误。）改了宿主半必须 `pnpm bundle` + **重启 dsh web**（宿主代码在内存里，不重启不生效）。

---

## 5. 怎么加载 / 迭代

前提：deepseek-harness 仓库已 `pnpm install` + `pnpm run build`，且 `DEEPSEEK_API_KEY` 已设。

```bash
# 一次性：把插件装进 web profile（= 在 ~/.dsh/profiles/web 里 pnpm add 本目录）
dsh plugin --profile web add <仓库根目录>

# 启动（--patch 手工挂载宿主 entry；package.json 的 dsh.bundle.patch 声明已让
# `dsh plugin add` 自动带上补丁，--patch 仅在 profile 于该声明存在之前加入时需要）
dsh web --patch <仓库根目录>/cordis.patch.yml

# 便携交付：tarball 自足——prepack 现场构建 lib/；白名单带 lib/src、安装脚本、双语文档、AGENTS、LICENSE 与补丁
npm pack
dsh plugin --profile web add <tgz 路径>
```

打开 http://127.0.0.1:3080，在**有未提交改动的 git worktree**里开一个会话，会话头出现状态卡。

**迭代循环**：
- 改客户端 → `pnpm exec tsdown` → **浏览器刷新**（host 会 stat-poll 新的 `lib/client.js`，刷新即生效）。
- 改宿主 → `pnpm bundle` → **重启 dsh web**（先杀掉占用 3080 的进程）→ 刷新。

> 树外的客户端插件**不会被 `pnpm dev:web` 监听**（它只 glob `packages/*/*/`）。开发要热更就自己开个 `pnpm watch`（= `tsdown --watch`），host 仍会 stat-poll 并广播 `rebuilt`。

---

## 6. ⚠️ 踩坑实录（接力模型必读）

这些都是花了真实调试才确认的。**别绕弯，直接照做。**

### 6.0 `git diff --no-index /dev/null <f>` 在 Windows 上不可用
git 会把 `/dev/null` 解析成仓库相对路径,报 `error: Could not access '...nul'`。未跟踪文件的内容 diff **不要用 git 合成**,直接在宿主 `fs.readFile` 后自己拼 unified diff 段(`diff --git a/x b/x` + `new file mode` + `@@ -0,0 +1,N @@` + 逐行加 `+`)。顺带行数精确、零 spawn。

### 6.0b `git status --porcelain` 默认折叠未跟踪目录
`?? .agents/` 一行代表整棵子树(曾导致 205 个文件只显示 3 行)。必须加 `--untracked-files=all` 逐文件枚举。

### 6.0c 轮询不得重置 UI 状态
15s 轮询每次返回**新的 `files` 数组引用**(内容相同)。若 `useEffect` 依赖该引用重置树的展开状态、或 bump gen 清按需 diff 缓存,用户就会看到"莫名其妙刷新、展开的目录缩回去"。规则:**树的展开状态提升到会话级组件**(轮询、关开面板都不丢);**gen 只在手动刷新时 bump**。

### 6.0d 关着的面板里，状态卡没有任何刷新路径
dsh 的 `session.header.cwd` 终身不可变，所以 `worktree_enter` 之后 sessions store **一个字段都不动**。绑定只有一处会读——deps 是 `[sessionId, worktreePath, fetchWorktreeStatus, open]`——四个全不变；而 3/15s 轮询第一行就是 `if (!open) return`。合起来：面板关着时状态卡是**挂载那一刻的快照**，agent 进了 worktree 它还写着 `main`，点开面板（唯一能翻 `open` 的动作）才追上。一个指示器最不该有的性质。

补法是一条**探针**而不是一条轮询：`sessionWorktree` 只读绑定 JSON、不 spawn git，安静时每次就是一次文件读；只有它跟状态卡上的绑定对不上（`bindingChanged`，用 `samePath` 比路径——裸比会把一次分隔符差异变成每 3s 一对 `worktree list` + `branch`）才补一次完整 `worktreeStatus`，把徽标和选择器要的 worktree 列表一并带回来。开表窗口卡死（`probesClosedBinding`）：**面板关着 且 agent 在跑**。绑定只可能在一个 turn 里动（enter/exit 是 agent 工具），而这个面板挂在**每一个** session header 上，空闲会话连 timer 都不开；turn 短于一个间隔时，deps 里的 `agentRunning` 在 turn 结束时重跑 effect 兜底问一次。

代价明摆着：**探针不看 agent 之外的改动**。探针经 RPC 直接改绑定（比如 `scripts/probe_worktree.py`）时 agent 没在跑，关着的状态卡就不会跟。这是选定的取舍，不是漏掉的分支。

### 6.1 宿主半必须用 `tsc` 构建，不能用 tsdown
tsdown/rolldown(oxc) **不会转译 stage-3 装饰器** `@Remote`——产物里会留下原始 `@Remote(...)`，Node 加载直接 SyntaxError。monorepo 里是先 `tsc -b` 转好再 tsdown 打包，所以没踩到。**树外必须自己用 tsc 产出 `lib/index.js`**（见 `tsconfig.json` + `package.json` 的 `bundle` 脚本）。

### 6.2 RPC 返回值必须 JSON-safe（`undefined` 会失败）
Typert gateway 对返回值做 `assertJsonValue`，**任何 `undefined` 属性值都会被拒**（报 `business result failed boundary validation`）。所以 `error` 字段在"无错误"时**必须整个键都不带**（声明为 `error?: string`，成功 return 里不写 `error`），不能写 `error: undefined`。

### 6.3 取数用 `ctx.subprocess.spawn`，不要用 `ctx.shell`
`ctx.shell`（bash-local/pwsh-local）在 Windows 上**通过 PTY 捕获输出，大输出会从头部被 scrollback 滚掉**：
- `git status --porcelain --branch` 的 `## branch` 头行丢失 → branch 显示空。
- `git diff HEAD`（几十 KB）的前几个文件整段丢失 → 点那些文件显示"未跟踪"。

**正确做法**：`ctx.subprocess.spawn({argv:['git',...], cwd, stdio:{stdin:'ignore',stdout:'pipe',stderr:'pipe'}, graceMs, signal})`，拿到 `handle.stdout` 这个 Readable，**自己 `for await` 累加所有 chunk**（管道没有 scrollback 上限，一字节不丢）。同时 drain stderr 防止管道死锁，`Promise.all([读stdout, 读stderr, handle.done])`。

### 6.4 客户端 bundle 必须是 closure-factory 形状
dsh 的 `ClientModuleSystem` 强制要求 `lib/client.js` 是这个外壳（**不能用普通 ESM/CJS**）：
```js
window.__ModuleLoader__.load({ id: "@young1lin/dsh-ui-gitworkbench", factory: (require) => {
  var module = { exports: {} }; var exports = module.exports;
  /* ...代码... */
  return module.exports;
} });
```
由 `tsdown.config.ts` 的 `outputOptions.banner/footer/intro` 注入。`react`/`react/jsx-runtime`/`@deepseek-ai/cordis` 等是 **external**（运行时由 loader 的冻结模块表 `require` 提供，**不进 node_modules 解析**）。`@deepseek-ai/*` 的 import 必须是**纯类型**（`import type`，编译时擦除），否则会被 bundle 纯度门拒绝。

### 6.5 `.npmrc` 必须关掉 auto-install-peers
`package.json` 的 `peerDependencies` 写了 `@deepseek-ai/*: "*"`。pnpm 默认会自动装 peer，于是去 npm 拉 `@deepseek-ai/dsh-client-runtime` 及其传递依赖——而有些包**没公开发布**（如 `dsh-compact`）→ 404。`.npmrc` 里 `auto-install-peers=false` + `strict-peer-dependencies=false` 解决。这些包**运行时由 web profile 提供**（`healProfilesModuleFallback` 把所有内置包软链进 `~/.dsh/profiles/node_modules`），本地不需要装。

### 6.6 CSS Modules 要自己 vendor lightningcss 插件
树外的 tsdown 没有 monorepo 那套 CSS Modules 插件。`tsdown.config.ts` 里 vendored 了 `dsh-css-modules-inline` 插件（`resolveId` 拦截 `*.module.css` → `load` 用 `lightningcss` 编译 → 注入 `<style data-plugin="...">` + 导出 class map）。所以需要 `pnpm add -D lightningcss`。组件里 `import css from './X.module.css'`。

### 6.7 ambient shim 让 tsc 在缺包时编译
宿主半 `import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'` 是**值 import**（不是 type-only），但本地没装这个包。`src/types/dsh-shim.d.ts` 用 `declare module` 给 cordis/subprocess/typert-protocol 写**宽松的类型**，让 tsc 能转译。tsconfig 要 `"types": ["node"]`（提供 `process`/`AbortSignal`）、`"experimentalDecorators": false`（stage-3）、`"strict": false`、`"noEmitOnError": false`。

### 6.8 路径参数用纯标识符
`@Remote` 方法在 SRC 发现模式下，gateway **靠 `Function.prototype.toString` 读参数名**。所以参数必须是**裸标识符**（不能解构/默认值/rest），且 `signal`（若要取消）必须放**最后**。`stats(worktreePath, signal)` 是合法的；SRC 下 `worktreePath` 可省略（客户端传 `{args:{}}`）。

### 6.9 端口 3080 被占用 → TaskStop 不够
`dsh web` 后台进程被 TaskStop 后，Windows 上 node 子进程可能还占着 3080，重启报 `EADDRINUSE`。要 `netstat -ano | grep :3080` 找 PID，`taskkill //F //T //PID <pid>`（`//T` 连子进程）杀干净再重启。

### 6.10 defineTool 注册 agent 工具的套路
- 类上要 **`static inject = ['subprocess', 'tools']`**——不加 `'tools'`，`ctx.tools` 不存在，`ctx.tools.register` 直接炸（工具服务要就绪才激活）。
- `description`/参数 `description` **写英文**（模型消费的语料，英文最稳），且把「进入后怎么用」写进去：file 工具加 `.agents/worktrees/<name>/` 前缀、shell 命令传 per-call workdir `.agents/worktrees/<name>`。
- **取会话**：`execute: async (args, exec) => { const session = exec.agent?.session; ... }`——sessionId 用 `session.id`、cwd 用 `session.header.cwd`（注意 `header.`）。**没有会话就拒绝**（返回 `{ok:false, error:'... requires a calling session'}`），别 fallback 到 `process.cwd()`（那会绑到宿主进程目录，语义错误）。
- 完整参照物：`packages/goal/tool-goal/src/index.ts`（本仓库 `src/index.ts` 的 `registerWorktreeTools` 就是照它写的）。

### 6.11 dsh-tools 的 JSON schema 子集：**不支持 type 数组**
- 工具的 parameters/output schema 走 dsh-tools 的受限 JSON-Schema 子集，**`type: ['object','null']` 这种数组会在插件加载时抛错**（整站起不来）。可空对象用 **`oneOf: [{type:'null'},{type:'object',...}]`**。
- 每个 object 节点**显式写 `additionalProperties`**（`false` 或 `true`，不写不行）。
- **输出 schema 必须容纳所有早退返回形状**：`worktree_status` 的无会话早退 `{ok:false, error}` 与正常 `{ok, binding, worktrees}` 共用一个 schema，所以 `ok`/`error` 声明为可选、`binding` 用 oneOf——否则真实调用时校验失败。
- **RPC 每长一个键，output schema 就要跟一个**：dsh 在模型看到结果之前按 schema 校验工具输出（`createSuccessResult` → `validateJsonSchemaValue`），`additionalProperties: false` 之下未声明的键不是「多一个字段」而是**每次调用都 `INVALID_TOOL_OUTPUT`**。`worktreeStatus` 为分支切换器长出 `remoteBranches`/`remoteBranchesTruncated`/`mainWorktreePath`（0.1.19）后 `worktree_status` 工具就一直在报错，抽屉直接读 RPC 所以没人发现，直到 code review 用 dsh 自己的校验器跑了一遍（四条 `is not a declared property`）。可空字符串同样写 `oneOf: [{type:'null'},{type:'string'}]`。守卫 `tests/worktree-status-schema.test.ts`：剥注释后把 schema 的属性表和 `worktreeStatus` 签名的返回类型键钉成相等（永远跑），本机 `@deepseek-ai/dsh-tools` 可解析时再用真校验器过 schema 与三种完整返回值（CI 里 peer 不存在、自动跳过）。

### 6.12 worktree 的 Windows 细节
- **`git worktree remove` 保留分支**（exit 从不删 `<name>`——可能有未合并提交）。之后再 enter：`worktree add -b <branch> <dir>`（`<branch>` = branchName ?? 名字）会因分支已存在而失败 → 先 `rev-parse --verify --quiet refs/heads/<branch>` 探测，幸存则改用 `worktree add <dir> <branch>` **检出既有分支**（hint 注明 reused，提醒模型里面有旧提交）——这也是「目录叫 X、落在既有分支 Y」的通路。
- **绑定文件的 rename 在 Windows 可能 EPERM**：页面 15s 轮询短暂持有读句柄/杀毒扫描，rename 撞上就 EPERM。做法：tmp + rename，EPERM 按 25/50/100/200/400ms 退避重试后再抛（见 `src/worktree.ts` 的 `saveBindings`）。
- **路径一律正斜杠规范化**：`rev-parse --show-toplevel` 的输出、porcelain 的 worktree path 都要做 `.replace(/\\/g,'/')` 再比对——宿主在 Windows 返回反斜杠，两边不统一就匹配不上（复用判定会失灵）。

### 6.13 宿主环境可能没有 git（PATH 缺失）
宿主 RPC 返回 `git status failed (exit N): <stderr>`（本插件的报错都带 exit code + stderr 尾部）时，先看 stderr——常见是宿主进程环境异常/git 不在 PATH，而不是目录真的不是仓库（2026-08-15 实例：目录明明是仓库却报 not a git worktree，重启 dsh web 换个健康环境即愈）。**报错透出 stderr 是定位这类问题的唯一手段**，新加 git 调用时照抄这个格式。

### 6.14 发布的 peer 范围不能写 `*`——`*` 按 `latest` dist-tag 解析
npm 7+ 自动安装 peer 时，`*` 走 **`latest` dist-tag**，不是「取版本列表最高」。`@deepseek-ai/*` 全系的 `latest` 长期停在 8 月 10 日的 `0.0.1-rc.1` 老线（那条线依赖**从未发布**的 `@deepseek-ai/dsh-compact`，公开安装必 404），能用的 `0.1.0-rc.x` 全挂 `next`。于是 0.1.2 之前任何不在 dsh profile 工作区里的裸 `npm i` 都炸 E404。规则：**peer 写显式区间**（cordis `^4.0.1-rc.1`、dsh 系 `^0.1.0-rc.2`），dsh 发新线时同步抬范围并验证 `npm pack` 出的 tarball 在空目录可装。注意 6.5 的 `auto-install-peers=false` 只管本仓库 pnpm 开发态，管不了用户侧 npm。

**补充（0.1.11）**：范围要留，但这些 peer 同时是 **optional**。它们全在 `tsdown.config.ts` 的 `CLIENT_EXTERNALS` 里——dsh 外壳把它们共享进自己那张冻结模块表，运行时由**加载插件的进程**提供，从来不该落进 profile 自己那层 `node_modules`。实测目录结构可以证明：`~/.dsh/profiles/web/node_modules/` 里只有插件自己，`@deepseek-ai/*` 全在上一层 `~/.dsh/profiles/node_modules/`,Node 逐级向上解析所以能找到；而 pnpm 的 peer 检查只看本层，于是把每一个都报成 missing——**官方自己的 `dsh-client-ui-file-reference` / `dsh-file-reference` 在同一次安装里打印一模一样的告警**，这条才是「这是平台常态，不是本包的缺陷」的证据。「消费者不该安装的 peer」按 npm 自己的定义就是 optional，所以 0.1.11 起 `peerDependenciesMeta` 把六个全标为 optional：告警消失，而**范围一个字没动**，peer 真的在场时仍然照常校验版本。验证方式是空目录装 `npm pack` 出的 tarball（`auto-install-peers=false`,与 dsh profile 一致）：改前 6 行 missing peer，改后零告警、exit 0、17 个 host 模块加 `client.js` 一个不少。

### 6.15 Windows 上裸 `--since=2026-08-18` 可能吃掉当天的提交
git 对裸 `yyyy-mm-dd` 的 `--since`/`--until` 解析带时刻语义，Windows 上一整天的提交可能全被排掉，且无任何报错。host 把裸日期展开为 `T00:00:00` / `T23:59:59` 再交给 git（`log-filter.ts`）——选中一天即指一整天，不赌平台行为。

### 6.16 带单文件 pathspec 的 `git status` 会把改名拆成「一删一增」
git 靠「一删除 + 一新增」的配对才认得出改名；pathspec 只放行一半时，status 报 `D` 加 `??`，而不是 `R`。任何**按 status 推导计划**的代码必须用全树 status 自行配对（`discard-ops.ts` 即因此不接受客户端传来的 status，一律重推），否则「撤销改名」会被计划成「还原一个、删掉另一个」——正好是用户没答应的那件事。

### 6.17 抽屉内新增模态层的 CSS 必须写 `.drawer > .xxx`，裸类会被压住
`.drawer > *:not(.resizer) { position: relative; z-index: 1 }`（布局需要）给了每个直接子元素 position 与层叠秩，裸类声明的 `position: absolute` 会输给它——遮罩被当作最后一个 flex 项排进抽屉底部的一条缝里，样式全对、位置全错、还不报错。模态遮罩一律写成 `.drawer > .confirmScrim` 这种带父作用域的选择器；`tests/drawer-chrome.test.ts` 有断言守着这条作用域与层叠秩。

### 6.18 CodeMirror 自带的界面只能在 `EditorView.theme` 里改，且查找面板不能用 flex 排版
`.cm-*` 是全局类名，写进 `.module.css` 会被 CSS Modules 哈希掉，选不中；改动一律走 TS 里的 `EditorView.theme`（`paneTheme` 与 `cm-search-theme.ts`）。这样写仍然吃得到调色板：面板挂在 `.overlay` 里，`var(--gs-*)` 照常解析。层叠也不用操心——`EditorView` 把 base theme 排在最前面挂载，同特异度下普通 theme 规则赢。

排版有个坑：`@codemirror/search` 用一个 `<br>` 分隔「查找行」和「替换行」，而 Blink **不给 flex 容器里的 `<br>` 生成盒子**——`flex-basis: 100%`、`width: 100%`、`min-width: 100%` 三种写法都在跑起来的应用上试过，替换框一律留在查找行上，样式全对、只是少了一次换行。面板因此保持行内流：控件写成 `inline-flex` 原子，行距用每个控件的下外边距承担，面板下内边距按这个边距扣掉；`scripts/verify_search_panel.py` 在真实抽屉里量这套版式。库自带的那套值全是字面量（`#f5f5f5` 的条、`linear-gradient` 的按钮、`1px solid silver` 的输入框、`#ffff0054` 的命中、外加一个不指定字体族的 `font-size: 70%`），一个都不跟主题走，必须逐条盖掉；`tests/cm-search-theme.test.ts` 按名字守着这份清单。

### 6.19 探针按类名选元素要用「后缀匹配」，读状态前要先把鼠标挪开

CSS Modules 的类名带每次构建都变的哈希前缀（`T3TXCq_file`），所以 Playwright 里
只能按局部名匹配。但 `[class*="file"]` 太松：它同时选中 `fileLi`、`filePath`、
`fileStatus`、`fileCountAdd`，第一版 `verify_vocabulary.py` 因此量到了外层
`<li>`，报告「树行没有圆角」——而圆角就在里面那个 `<button>` 上。按后缀判断才准：

```js
const local = (name) => [...document.querySelectorAll('[class]')]
    .filter(el => [...el.classList].some(c => c === name || c.endsWith('_' + name)));
```

第二个坑在特异度上：`.file:hover` 是 (0,2,0)，裸修饰符 `.fileActive` 只有
(0,1,0)，悬停规则必然赢。Playwright 点完一行，指针就停在那行上，`getComputedStyle`
读回来的是**悬停态**，于是选中态的强调色底会被读成中性的 `--gs-raise`。读状态前
先 `page.mouse.move(4, 4)`。抽屉里所有选中行都是这个行为：指针压上去时底色让位给
悬停，强调色文字、600 字重和左侧强调边仍在，选中依然读得出来。

还有一条：规则写在子元素上时要读子元素。`.calWeek` 的 11px 写在 `.calWeek span`
上，读容器拿到的是从面板继承来的 12px，看起来像漂移。

### 6.20 行号槽是内联 `position: sticky`，把它改成透明就等于让代码从行号底下穿过去

`@codemirror/view` 在 gutter 插件里用**内联样式**写死 `this.dom.style.position =
"sticky"`（`dist/index.js` 约 11398 行），CSS 覆盖不掉。于是横向滚动时行号钉在面板
左缘不动，代码从底下滑过去——库自带 `background: #f5f5f5` 正是为了挡住这一幕，
`paneTheme` 早先把它改成了 `transparent`，行号和代码就叠印在一起了（在跑起来的
应用上拍到过：`pl0ügin`、`ull5neutral`、`cro3ssed`）。所以 gutter 必须有自己的底色，
用面板的地色 `--gs-surface`——Files 的 `.fbMain` 和 Changes 的 `.diffPane` 都是它。
自己上色的格子（活动行、改动行）照旧盖在上面，跟盖在透明上没有区别。

只补底色还差一截：`left: 0` 把 gutter 钉在滚动容器的**内容盒**边缘，而面板是在
**内边距盒**上裁剪的，所以代码会继续从面板那 8px 左内边距里钻出来，露在行号左边。
底色因此要向左溢出：`boxShadow: '-16px 0 0 0 var(--gs-surface)'`。颜色就是地色、
又被面板裁掉，多溢一点不要钱。

两个连带项。一是 `.cmHost[data-editable]::before` 那条可编辑轨条压着 gutter 头两个
像素，而 CodeMirror 把 gutter 叠在 `z-index: 200`——轨条得写 `z-index: 201`，否则
gutter 一变不透明就把它埋了。二是探针查不了这件事：`elementFromPoint` 不做
box-shadow 的命中测试，行号左边那个点照样报 `.cm-line`，只能截图看像素，或者直接
读 `getComputedStyle(...).boxShadow`。`scripts/verify_gutter.py` 走的是后者。

### 6.21 探针「什么都没测到」的三种样子，都不长得像失败

探针最贵的失败不是断言变红，是它根本没走到要测的那一步，然后一路 PASS 或者报一个
和真实原因无关的超时。三条都是在实机上撞出来的：

**一、会话列表默认是折叠的。** 侧边栏按 workspace 分组，全新的无头上下文拿到的
`dsh.workspace.view.v5` 里 `groupExpansion` 是空对象——每个组都收着，会话行**根本不在
DOM 里**。`page.get_by_text('会话标题')` 于是永远找不到，再怎么等也没用。要先把
`[class*="projectRow"][aria-expanded="false"]` 一个个点开。这条会伪装成
`Locator.click: Timeout 30000ms exceeded` 卡在 `cardBranch` 上，看起来像抽屉没渲染。

**二、Changes 侧不点「编辑」就没有编辑器。** 未武装时 diff 右列渲染的是 `<span>`
（`DiffViews.tsx`），CodeMirror 只在 `layer === 'unstaged' && edit.armed` 时挂载。
探针点开一个改动文件就去找 `.cm-gutters`，找不到是**对的**——但如果把这种情况写成
`SKIP`，那一整段就永远不会被验，而它看起来一直是绿的。要么点「编辑」把它武装起来，
要么把「未武装时不该有编辑器」写成一条真断言。

**三、挑文件别用 `.first`。** fixture-01 的第一个改动文件是 PNG，二进制文件走的是
「无文本差异」分支：没有分栏、没有「编辑」按钮、没有编辑器。用 `.first` 拿到它，
报出来的是「分栏视图没有编辑按钮」——一个从来不存在的 bug。按扩展名挑一个文本文件。

还有一条量级的：**把窗格「拖窄到一定要横向滚动」不能写死像素**。`320px` 在 Files 侧
够窄，在 Changes 右列比最长的行还宽，于是 `scrollLeft` 停在 0，那一段测的是「没滚动
所以没有重叠」——不是「没有 bug」。按 gutter 自身宽度加一条缝算目标宽度，再
`scrollLeft = scrollWidth` 滚到底，重叠就一定发生在最坏处。

### 6.22 workspace 打开的是仓库子目录时，Changes 列得出文件、点开全是空白

git 的两种「路径」只在仓库根相等：`git status --porcelain` 和 `git diff --numstat`
无论在哪个目录运行，输出的都是**仓库根相对**路径（抽屉里的 `path` 全部来自这里）；
而 pathspec、`:path` 版本语法、`hash-object` 的文件参数、`ls-tree` 的清单，全部相对
**当前运行目录**解析。会话打开的就是仓库根时两者天然一致；一旦 workspace 打开的是
子目录（如 git 根在 `C:/mattermost/`、workspace 开在 `C:/mattermost/server`），抽屉就
成了「树是对的，其余全空」：`diff HEAD -- server/main.go` 在 `server/` 下运行会去找
`server/server/main.go`，匹配不到，**exit 0、空输出**——点开改动文件一片空白，任何错
都不报；勾选暂存报 `pathspec did not match`；blame 直接 fatal；`ls-tree` 从子目录吐出
**剥掉前缀**的清单，路径选择器给历史过滤喂的 pathspec 从此永远匹配不到；宿主侧
`join(cwd, path)` 读未跟踪文件同样拼出双前缀路径（实测 git for Windows：同一条
`diff HEAD -- server/main.go`，在根 11 行，在 `server/` 0 行）。

修法：所有带路径的 RPC 先解析一次仓库根（`rev-parse --show-toplevel`，纯模块
`src/repo-root.ts` 的 `rootedDir`；不在仓库里则回落原目录，让调用方自己的 git 失败
照旧冒出来），git 与文件读全部在根上做。`stats` 是轮询的，这次解析并进它已有的
并行批次，墙钟零增加（status/numstat/rev-parse 本就 cwd 无关，仍跑在会话目录）；
`commitStats` 把解析放在缓存探测之后，命中不多花 spawn。刻意**不缓存**解析结果：
会话中途在子目录里 `git init`，下一次轮询就该认到新根。守卫两条：
`tests/repo-root.git.test.ts` 把 git 侧行为逐条钉死（子目录下 pathspec 匹配不到、
`ls-tree` 剥前缀、`hash-object` 双前缀报错——git 哪天改了行为它会先叫）；
`tests/host-rooted-paths.test.ts` 源码扫描钉布线（先剥注释；断言带 path 的 @Remote
恰好十一个、每个方法体内必须出现 `rootedDirOf`；已做变异测试，改掉一个方法它会点名）。

### 6.23 「永远 modified」的 CRLF 幻影：status 列着 M、diff 永远为空

仓库字节 + `core.autocrlf=true`（或 `eol=crlf` 属性）的组合下，git 的 stat 检查与
clean 过滤对同一文件给出**相反答案**：`git status` 永远报 modified（smudge 方向认为
重新检出会不一样），`git diff` / `--numstat` 永远为空（clean 方向认为内容一致）。
实测两种形态稳定复现（LF 入库 + autocrlf=true + CRLF 工作区；`v.txt eol=crlf` 属性 +
LF 入库 + CRLF 工作区），touch 失效 stat 缓存后反复 status **不会**自愈。注意反例：
CRLF 字节**入库**（autocrlf 翻转之前提交的）反而报干净——不是「有 CRLF 就有幻影」，
条件是「入库字节经 clean 后与工作区一致、经 smudge 后与工作区不一致」。

抽屉此前把它显示成一行无人解释的「无文本差异」——树里挂着 M、点开却什么都不说，
读起来就是显示坏了；unified 视图更糟：`fileDiff` 的 untracked fallback **不查 tracked
状态**，会给幻影文件合成出 git 自己都看不见的「整文件新增」段。修复三层：
空 diff + 树行状态为 modified（`isPhantomModified`，diff-model.ts——fully-staged 文件
的 unstaged 层也空，但整文件 HEAD-diff 非空，所以判据必须用**整文件**段而不是当前层）
→ 面板解释这是行尾归一化幻影并建议统一 LF（locale `phantomNotice`；cr-visible 的
LF 建议守卫计数 2→3）；`fileDiff` 合成前先过 `isUntracked`；Compare 的另一半是
**三点语义方向**——`A...B` 比较分叉点到 B，两端选反时**整个文件树为空**（RPC 的
files/numstat 全 0），此前一个字不说，现在给一行「想看另一方向请交换两端」
（`compareEmptyHint`）。守卫：`tests/phantom-notice.test.ts`（判定）、
`tests/crlf-pipeline.test.ts`（CRLF 行在解析与两遍着色中逐行自对齐——排查时管线已
被排除，钉住它继续被排除）；活体验证 `scripts/verify_crlf_display.py`（本地，5 项断言：
幻影提示出现、fileDiff 空返回、真差异照常渲染、方向提示出现、仅行尾对比带 ␍ 渲染）。

### 6.24 side-by-side 两列必须用整文件 pass 着色；Shiki 的 token 里没有 CRLF 的 CR
并排视图的每一列本来就是**整文件**（`git diff -U1000000` 是覆盖全部的一个 hunk），所以知道块注释与模板字面量边界的整文件 pass 才是它的答案；`highlightWindow` 的逐行 re-lex 是给 unified diff 的**重建**规则——列没有被重建过，逐行冷启动重 lex 会把 JSX `{/* … */}` 无星号续行里的散文涂成关键字（`switch`、`in` 在句子里发亮）。两列与编辑器统一走 `highlightRange`（左右列缓存键分开，经 `token-cache.ts` 分块，新 chunk 一次调用、回滚不重算）。另一半：Shiki 按 `\r?\n` 切分输入，CRLF 行的 CR 不进任何 token，而渲染器从 runs 画 CR 字形且「一行的 runs 必须拼回该行」——`runsOf` 把丢掉的尾部补回最后一个 run。守卫 `tests/side-pane-syntax.test.ts` 用 **AST** 提取 `DiffViews.tsx` 的全部调用名断言 `highlightWindow` 不再被调用（文本扫描已被注释里的散文满足过两次）。

---

### 6.25 sticky 只对最近的滚动容器负责；CodeMirror 的面板要指 topContainer，且条只认它搜的那列
`position: sticky` 解析到**最近的滚动容器**——任何 `overflow` 非 `visible` 的元素都算（并排列的 `overflow-x: auto` 就算），不一定是真正滚动的那个（`.sideScroll`）。CodeMirror 把查找面板 `.cm-panels` 挂成 `.cm-editor` 的第一个子节点并 `sticky; top: 0`，于是并排面板里面板粘在列上：列和文件一样高、纵向永远不滚，面板随第 1 行滚出视野（Enter 找到下一个命中、输入框没了），面板高度还把右列压低而左列不动、行对齐破掉；Files 页没有这问题（`.fbBody` 既是父级也是滚动容器）。修法是库自带的 `panels({ topContainer })`：面板挂进 `SideFindSeat`（`DiffFindBar.tsx`），位于 `.sideScroll` 上方、**只压工作树列**——条搜的是缓冲区，横跨两列等于谎报搜索范围；行镜像 `.sideCols` 的几何（split 占位 + 与 `.paneDivider` 等宽的槽 + 宿主，`css-modules.test.ts` 把三处 7px 绑在一起），右侧让出 `.sideScroll` 的滚动条槽（`use-scroll-gutter.ts` 量 `offsetWidth - clientWidth`）。两处易漏：计数插件的 `querySelector` 要先查宿主再回落 `view.dom`，否则 `3/128` 消失；Ctrl/Cmd+F 只能在未武装时交给 `DiffFindBar`（CodeMirror 不吞武装后的键，不设卫会两个查找同时开）。守卫 `tests/find-panel-host.test.ts`（剥注释源扫描 + 变异验证），实机探针 `scripts/verify_side_find.py`（本地）。

### 6.26 「是不是主工作树」宿主和客户端各判一次，答案必须来自同一个东西：树的根
主工作树的判断有两处。宿主 `switchBranch` 先 `rootedDirOf` 再 `isMainWorktree`——子目录会话解析到根、判为主树、接受调用。客户端决定渲染与否的门若拿 `mainWorktreePath`（`git worktree list` 首行 = 仓库根）与 `statsPath`（会话打开的目录）比，`A` ≠ `A/B`，控件不渲染、不报错、无从发现——宿主能做的事被 UI 藏掉，比拒绝更糟（用户实报：dsh 开在子目录 B，`.git` 在上层 A）。规则：门比的是所看那棵树的**根**，`stats.repoRoot`（`--show-toplevel`；`stats` 为读未跟踪文件本就解析了它，返回不多花 spawn），与 `mainWorktreePath` 走 `samePath`——一个来自 `worktree list`、一个来自 `show-toplevel`，两者拼写一致是 git 契约，`tests/branch-switch.git.test.ts` 钉住。切源时用 `rootOfWorktree` 从 worktree 列表预填 `repoRoot`，否则占位 stats 没有根、切换器要等 stats 抓完（大仓库以秒计）才弹入；子目录不在列表里，就等 git 的答案——猜「路径本身」会把它藏回去。**不要走客户端前缀匹配**（「`statsPath` 以 `mainWorktreePath` 开头」）：本仓库的 worktree 就建在 `<root>/.agents/worktrees/` **之内**，前缀法会把 linked worktree 认成主树；嵌套的另一个仓库同理——哪棵树归谁只有 git 说了算。守卫 `tests/switcher-gate.test.ts`（剥注释源扫描，钉门只比 `stats.repoRoot`、不碰 `statsPath`/`sessionPath`/`stats.worktreePath`，变异验证）；实机探针 `scripts/verify_subdir_switch.py`（本地，在 fixture 的 `samples/go` 注册 workspace 复现并自清理）。

### 6.27 「会话 cwd」不是「仓库根」：这是同一个坑的第三次，凡把 cwd 当根用的地方都要过一遍
6.22（带路径的 RPC）、6.26（切换器的门）之后，同一前提又在两处露出来——dsh 的 workspace 可以开在仓库的任一子目录，而插件里凡是默认「会话 cwd = 仓库根」的地方都在那种会话里悄悄出错。其一在宿主：`worktree_enter` 把 worktree 建在 `<root>/.agents/worktrees/<name>`，返回的 hint、每轮注入的 `worktree:binding` 提示、工具描述却都说「相对会话 cwd 用 `.agents/worktrees/<name>`」——开在 `<root>/server` 的会话里那个目录根本不存在，文件工具照提示加前缀，文件就落到 `server/.agents/worktrees/<name>/…`，**在主树里、未跟踪，agent 还以为自己在 worktree 里**，没有任何报错。规则：前缀由 `worktreeRel(cwd, worktreePath)`（worktree.ts）从会话 cwd 真正解析（`node:path` 的 `relative`，两边先统一正斜杠，结果再统一；相等答 `.`），子目录得 `../.agents/worktrees/<name>`；hint 与提示两处都用它，提示里别再断言「工作目录仍是仓库根」。守卫 `tests/worktree-rel-wiring.test.ts`（剥注释扫描 index.ts 两处调用点，逐点变异）。其二在客户端：面板三处回答「这是不是会话自己的树」——源选择器的当前行与 `●`、切源时「选自己的树则清除覆盖而非钉住」、绑定变化时丢掉这种钉住让视图跟着 agent——比的都是原始 cwd，`A/B` 在 worktree 列表里谁也不是：没有行亮、点主树行变成钉住、`worktree_enter` 后抽屉留在原地。规则：宿主 `worktreeStatus` 把它本就解析了的调用方根作为 `repoRoot` 返回（仓库外 `null`，绝不 `undefined`——RPC 返回值必须 JSON-safe），客户端 `sessionTree(cwd, repoRoot)`（worktree-view.ts）在 cwd 比根深时用根、cwd 就是根时**保留 cwd 自己的拼写**（挂载时的 stats 抓取以这个字符串做 effect key，只换拼写会让每个会话头都多抓一次；子目录会话则在根到手时确实多抓一次——`statsPath` 换值触发按源重置，芯片计数闪一次 `—` 再回来，仅挂载时一次，`statsPathRef` 挡掉过期响应）；三处读 `sessionRoot`、比较走 `samePath`。守卫在 `tests/switcher-gate.test.ts` 追加（面板三处接线 + 宿主字段，逐点变异）。排查方法：把 workspace 开到 `gitworkbench-fixture/samples/go`（fixture 仓库的子目录）过一遍抽屉与 agent 工具——凡是根会话正常、子目录会话不对的，都是这个坑。

### 6.28 首次 push 的 remote 不能写死 `origin`：`origin` 是 `git clone` 的习惯，不是 git 的要求
分支无 upstream 时 argv 曾写死 `push --set-upstream origin <branch>`，而同步条只要 `git remote` 有输出就显示 push 按钮：remote 改过名的 clone、手动 `remote add upstream` 的仓库，按钮照常出现、点下去 git 报 `'origin' does not appear to be a git repository`。规则：`pushRemote(remotes, pushDefault)`（git-ops.ts）决定去处，顺序照 git 自己对无 upstream 分支的顺序——`branch.<name>.pushRemote`、其次 `remote.pushDefault`、再 `origin`——再加一条 git 不猜但抽屉可以猜的：只有一个 remote 就是它；多个且无 origin 才拒绝，并把 remote 名和该设的配置键写进错误；配置指向的名字不在 remote 列表里（`remote rename` 后的陈旧值）同样拒绝并点名配置键，不把 git 的 `does not appear to be a git repository` 直接甩给用户。`pushArgv(branch, remote | null)`：null 表示已有 upstream、仍是裸 `push`（尊重用户自己的 push 配置）；remote 名同分支名一样过 `isSafePathArg`，以 `-` 开头的名字不能变成参数。RPC 只在无 upstream 的路径上并行问 `git remote`、`git config --get branch.<name>.pushRemote` 与 `remote.pushDefault`（未设时 exit 1、stdout 空，当 '' 用；分支键优先——`tests/push-remote.git.test.ts` 钉住）。守卫 `tests/push-remote.git.test.ts` 在真 git 上钉住事实：唯一 remote 叫 `upstream` 的仓库，旧 argv 失败、新 argv 落地且 `main@{upstream}` = `upstream/main`。

## 7. dsh 仓库里的关键参考文件（去哪里抄）

接手改这个插件时，对照这些原文件（路径相对 dsh 仓库根；开发机上它是本仓库的兄弟目录 `../deepseek-harness`）：

| 要做什么 | 看哪里 |
|---|---|
| 抄一个完整客户端插件的套路 | `packages/client/ui-jobs/`（package.json 的 `dsh.client`、`src/client/index.ts` 的插槽注册、`.module.css`） |
| 插槽 API / 组件 props 类型 | `packages/client/ui-slots/src/index.ts`（`SlotMap`、`PropsRuntime`、`register`） |
| 原生 diff 组件（如果想复用） | `packages/client/ui-primitives/src/DiffBlock.tsx`（吃 `{path,oldText,newText}[]`，红删绿增） |
| 主题 token 名 | `packages/client/ui-jobs/src/client/*.module.css`、`ui-primitives/src/DiffBlock.module.css`（`--dsw-alias-*`、`--dsw-alias-state-success/error-primary`） |
| 客户端 bundle 格式 / 纯度门 / CSS 插件 | `packages/client/tsdown.client.ts`（本插件的 tsdown 配置就是从这里 vendored 的） |
| Typert 宿主发布（`@Remote`） | `packages/typert/protocol/src/index.ts`（`TypertRemoteService`、`Remote`、`remoteMethods`）；真实例子 `packages/goal/goal/src/index.ts` |
| 注册 agent 工具（`defineTool`） | `packages/goal/tool-goal/src/index.ts`（`inject` 加 `'tools'`、`exec.agent.session` 取会话、presentCall 卡片）；schema 子集与 `cloneJson` 见 `@deepseek-ai/dsh-tools` / `packages/core/tools` |
| 客户端 RPC 调用形态 | `packages/client/connection/src/client/rpc.ts`（`connection.rpc.call(channel, endpoint, payload, signal)`） |
| `/api` 派发（gateway 拦截器只有一个） | `packages/client/connection/src/rpc-host.ts`；`packages/api/gateway/src/index.ts` |
| subprocess spawn API | `packages/subprocess/subprocess/src/types.ts`（`SubprocessSpawnSpec`、`SubprocessHandle`、`CollectedOutput`） |
| 出树加载（`dsh plugin add` = pnpm 转发） | `apps/cli/src/plugin.ts`；profile 组合 `packages/boot/app-boot/src/profile.ts` |

---

## 8. 可继续做的事（给接力模型的点子）

- **行号单列 / 双列可选**：现在是双列（老/新）。可加开关。
- **会话级基准**：当前基准是"工作区 vs HEAD"。若要"本次会话以来的变更"，需在会话开始时快照 git tree OID 并持久化，再相对它 diff（复杂度高）。
- **更多主题族**：加一族＝ `themes.ts` 加一行 + `.module.css` 加两块调色板，`tests/theme-palettes.test.ts` 会盯着两边对齐。
- **样式作用域再细一层**：现在是「项目 / 全局」两级。若要「按 worktree」再加一层，`style-store.ts` 的 projects 换成两级 key 即可，解析顺序在 `effectiveBackground`/`effectiveCss` 一处改。
- **复用 DiffBlock**：若不需要文件列表/行号，可直接用 `@deepseek-ai/dsh-client-ui-primitives` 的 `DiffBlock`（把统一 diff 解析成 `{path,oldText,newText}[]` 喂给它），更省事但定制性低。

---

## 9. 设计基准

- 「此次变更」= **工作区相对 HEAD 的未提交改动**（`git diff HEAD` + `git status --untracked-files=all`）。行数:tracked 来自 `--numstat`,untracked 来自宿主合成时的精确行数统计。
- 未跟踪文件:宿主 `fs.readFile` 合成 diff 段(见 §6.0),单文件 >1MB 只计数不合 diff;随包总量上限 160KB,超出部分点击时走 `gitWorkbench/fileDiff` RPC **按需加载**(tracked 用 `git diff HEAD -- <path>`)。
- 二进制判定:numstat 的 `-` 计数,或未跟踪文件前 8KB 含 NUL 字节。二进制文件不计行数；字节嗅探为图片（8 种浏览器能画的格式）的直接显示——变更页读工作区、删除的读 HEAD；历史页读该提交、删除的读第一父提交；对比页读 head、删除的读 base（`image-source.ts`）——其余显示占位。
- diff 文本总量上限 400 KB（`DIFF_CHAR_CAP`），超出截断。
- 环境**卡**（非状态卡）常驻会话头:branch/detached + ↑↓ ahead-behind + `+N −M 文件数`。
- 左侧为**可折叠文件树**:目录节点带文件数徽章与聚合 +N/−N;>12 文件的目录默认折叠;「展开全部/收起全部」;选中文件自动展开祖先链;展开状态会话级持久(见 §6.0c)。
- 词级高亮 = 相邻 −/+ 行按 token LCS 对齐(`diff-model.ts`),行底色之上叠加强调色;语法着色 = **Shiki**(`highlight.ts`:本地包、Oniguruma WASM 引擎（内联进包、异步实例化，抽屉一打开就预热）、语法按需分包加载)——`lib/client.js` 2.3MB 的主因即它。bundle 纯度门禁的是 `@deepseek-ai/*` 的**值导入**(运行时由 profile 提供),不是第三方库;早期「正则单遍扫描」的实现已被替换。
- **状态卡是会话的环境信息位**：git 仓库内常驻显示分支（或 detached sha）+↑↓+计数，**干净树也显示**；仅 `stats.error`（非 git 目录 / git 不可用）时隐藏。绑定标记 = 树形图标：插件所建 worktree 的分支就是名字本身（旧绑定为 `wt/<name>`），徽标印名只会把分支名说两遍，所以只留图标；外部建的 worktree 徽标 = 图标+name——那是唯一点名目录的地方。
- **面板是浮起的卡片**（四边留白 + 圆角 + 投影），左缘可拖拽改宽、有最大化满屏；宽度与外观都存 localStorage，且读回时校验（旧版本写的族名不会漏到 `data-gs-theme` 上）。
- **明暗默认跟随操作系统**（`prefers-color-scheme`），可显式覆盖；主题族 7 套（GitHub / IntelliJ IDEA / VS Code / One / Solarized / Nord / Cyberpunk）各带亮暗。面板内滚动条也按当前调色板重绘——**按类名逐个列举是不行的**：文件树那栏改过名之后就一直漏在外面、保持系统原生的浅色滚动条，所以规则写成 `.drawer *`。
- **背景图与自定义 CSS 按「项目 / 全局」两个作用域存在宿主**（`~/.dsh/gitworkbench-style.json`），项目优先；背景图整条取项目的，自定义 CSS 两边叠加、项目在后。详见 §2.3b。
- **worktree 语义**：
  - 目录 = 仓库根下 `.agents/worktrees/<name>`（仓库内，无沙箱越界）；**分支 = 名字本身，不加强制前缀**。name 规则 = git ref 字符集 ∩ Windows 目录名：字母/数字开头，可用 `. _ - +`，最长 64；拒绝 `..`、尾部点、`.lock` 结尾、Windows 保留名（CON/NUL 等）与 `head`；非法或缺省自动生成 `worktree-<hex6>`。
  - **退出默认保留目录**，`remove:true` 才删；删除前 `git status --porcelain` 检查，**脏树拒绝且绝不加 `--force`**（保守，防丢改动）。
  - **会话 cwd 不可变**（dsh 本体约束）：enter 不切 cwd，而是返回 hint 指引模型——file 工具用 `.agents/worktrees/<name>/` 前缀的相对路径，shell 命令传 per-call workdir `.agents/worktrees/<name>`（相对会话 cwd 解析）。
  - **再进入**：目录仍是注册 worktree（含外部工具建的、经 Junction 映射的——realpath 判定）→ 直接复用、只补绑定并保留其分支；目录已删但分支 `<name>` 幸存 → `worktree add <dir> <name>` 检出旧分支（hint 注明 reused）。
  - **绑定**（per-session）持久化于 `~/.dsh/gitworkbench-worktree-bindings.json`；损坏/缺失视为无绑定并重建。写入原子（tmp+rename）+ 互斥（promise 队列）+ EPERM 退避重试（§6.12）。
  - **子代理借绑定、不写键**：`agent/session-start` 事件把子会话 header 的 `parentSession` 喂进宿主 `parentOf` 表（提示回调还会从活 header 自愈补第一跳，兜插件重载）；standing 提示、芯片/抽屉、`worktree_status`、`sessionWorktree` 统一经 `resolveEffectiveBinding`（worktree.ts：**自有绑定优先**，miss 沿父链借最近绑定祖先，环检测 + 8 跳上限）解析有效绑定。只读不写——外层 `worktree_exit` 后子树下次读取自动失去借用（更高祖先仍绑定时向上翻转）；子会话自己 `worktree_enter` 以自有绑定遮蔽继承。`worktree_exit` 对无自有绑定的会话报错并指明绑定属父会话。宿主重启后空闲会话的谱系边要等其 loop 恢复才有——查询退化为无继承（即旧版行为，fail-soft）。
  - **状态卡纪律例外**：有绑定时即使 bound worktree 干净也显示状态卡——绑定标记（树形图标）是绑定指示器与面板入口；面板打开期间空视图也保持挂载（可从空源切走）。头部选择器只改显示对象，不动绑定。
