@guowenzhang/dsh-worktree
在独立的 Git worktree 里开一个 DSH 会话:创建 checkout、把它注册成一个独立工作区、并让会话以它为工作目录启动。
它解决什么
DSH 的会话工作目录是创建时冻结的不可变头字段(SessionHeader.cwd),bash 工作目录、文件读写围栏、workspace-write 沙箱授权根、终端启动位置、文件名搜索根全部由它推导。所以"进入另一个目录"不是切换状态,而是开一个新会话。
本插件把这件事变成一次操作:勾选 → 创建 worktree → 新会话就跑在里面。隔离强度来自 DSH 既有机制,不需要改动 harness 任何一行。
组件
| 组件 | 位置 | 作用 |
|---|---|---|
worktree 服务 |
src/service.ts |
仓库根解析、创建/列举/删除 linked worktree、按布局推导默认路径 |
| 模型工具 | src/tools.ts |
worktree_create / worktree_list / worktree_remove |
| Host 路由 | src/route.ts |
POST /worktree/api/{start,list,branches}:原子地"建 checkout → 注册工作区 → 开会话 → 把会话挂进该工作区",并给界面提供本地分支列表 |
| 浏览器控件 | src/client/ |
新会话屏幕 选择工作区 / 模式 那一行右侧的一个胶囊:左边是本地分支下拉,右边是 worktree 勾选 |
用起来
1. 挂载
dsh plugin --profile web add C:/path/to/dsh-worktree
或手工在 profile 的 cordis.patch.yml 里:
- insert:
- id: worktree
name: '@guowenzhang/dsh-worktree'
host 半边是进程内模块,改完要重启宿主;浏览器半边刷新页面即可。
2. 从界面用
在 Git 仓库里开新会话时,选择工作区 / 模式 那一行的右端出现一个胶囊:⑂ <分支> ▾ │ ☑ worktree。左边是本地分支下拉(新分支从哪个本地分支开始,默认是当前会话所在 checkout 的分支,取不到就是 HEAD;列表在打开菜单时才去读,按最近提交排序,只列 refs/heads——远端分支或 tag 会让 git worktree add 悄悄进 detached HEAD,所以不给选),右边勾上即创建独立 worktree 并立即在里面开启会话:
- 在主仓库上执行
git worktree add -b <branch> <path> <base>(会话即使已经在某个 linked worktree 里,也会回溯到主仓库)。分支名由 Host 自动生成:所选 base 分支名(/换成-)+ 6 位随机数字,例如dev-482913;base 不是本地分支(HEAD、某个 SHA、origin/x)时用worktree-<6 位随机数字>。界面不需要输入分支名。默认路径是当前工作区目录下的.agents/worktree/<分支名>,缺的每一级都会自动创建——这样它在工作区树里挂在当前工作区下面,而不是变成旁边一个平级目录;旧行为(仓库旁边<repo>-wt-<branch>)用defaultPath: sibling保留; - 把新目录注册成一个工作区,标题形如
repo · dev-482913; - 用新目录作为
meta.cwd启动会话; - 把该会话挂进工作区的账本(
workspace.attachSession)。工作区归属只认这本账:不挂的话会话在 GUI 里是"未分组",而选择工作区也没有名字可显示——所以这一步失败会单独报[attach],并且不删 checkout(会话已经在里面跑着了)。
浏览器侧再刷新一次会话目录表,然后切到该会话——新会话是 Host 在客户端 Session Controller 之外创建的,不先拉一次目录,导航会以 unknown session 拒绝这个 id,人就会停在原来的会话里。
勾选是单向的:勾上就执行创建,没有"取消勾选"的路径。失败分两种:Host 拒绝(分支名冲突、不是仓库等)什么都没建,控件回到未勾选并显示原因,再勾是重试;建好了但没切过去(导航失败)时控件记住那个新会话,再勾是把它打开,不会建第二个 checkout。每个会话独立、且不可更改:会话的工作目录是创建时冻结的头字段,所以这个控件只在会话还是空白(没跑过第一轮)时出现;跑过之后它就不显示了——想换目录只能新开会话。这是设计约束,不是缺陷。
已在 worktree 里的会话:控件继续显示,但锁住。 它显示当初选的 base 分支和已勾选的 worktree(chevron 收起、两段都禁用),悬停提示当前实际所在分支。这样切过去之后既不会"看错分支",也不会再点一次在一个 checkout 里套出下一个。页面刷新会丢掉"这个 checkout 是我建的"这层记忆,那时锁住的控件显示该 checkout 自己的分支——分支名本身就是 <base>-<6 位随机数字>,来源照样看得出来。
| 状态 | 控件 | 原因 |
|---|---|---|
| 空白会话 + 目录在主 checkout 里 | 可操作 | 还能选 |
| 会话已在某个 linked worktree 里 | 显示但锁住(base + 已勾选) | 它已经隔离好了;再给一次会在一个 checkout 里套出下一个 |
| 会话已跑过至少一轮 | 隐藏 | header.cwd 已冻结,Host 会拒绝 |
| 目录不是 Git 仓库 | 隐藏 | 没有可隔离的仓库 |
3. 从命令行 / 模型用
// 一个会话里
worktree_create({ branch: 'fix/123' })
worktree_list()
worktree_remove({ path: '/abs/path' })
设计约束(为什么这么做)
会变成一个新工作区——这是刻意的
Workspace 的身份判据是 fs.realpath 之后的路径字符串相等(dsh-workspace 的唯一 canon),会话归属又靠 header.cwd 的 realpath 相等。worktree 路径与主 checkout 是两个不同的 realpath,所以它必然是一个新工作区。
不把它硬塞进主工作区,是因为那需要把"相等"判据换成"前缀包含",会让 C:\repo 和 C:\repo-subdir 互相污染。用带分支名的标题让并列条目可读,是这个代价的正确付法。
三个动作必须原子
顺序被工作区注册表强制:会话只在 header.cwd 的 realpath 等于工作区 path 时才归属,不一致会 fail loud。所以必须是 checkout → 工作区记录 → 会话,且:
- 工作区创建失败 → 删掉 checkout;
- 会话启动失败 → 删掉 checkout(工作区记录保留,可用侧边栏删除);
- 清理也失败 → 错误里点名目录,绝不静默留下垃圾。
记忆不隔离,是有意的
dsh-memory / dsh-claude-compat 会把 linked worktree 的 {project} 回溯到主仓库,所以同一仓库的所有 worktree 共享一份记忆。最终形态:
| 维度 | 行为 |
|---|---|
| 工作区分组 | worktree 独立 |
| 文件与沙箱授权 | 各自独立 |
记忆 / {project} |
共享主仓库 |
隔离的是文件和授权,共享的是知识与上下文。
失败模式
| 情况 | 行为 |
|---|---|
| 目标分支已存在 | 创建失败,不留残留记录(自动 worktree prune) |
| 分支名非法 | 在启动 git 之前拒绝;浏览器也先行拒绝,两侧共用同一条规则 |
| 目录有改动 | worktree_remove 拒绝;要删需显式 force |
| 删除主 checkout | 拒绝 |
| 删除未列出的路径 | 拒绝(请求路径先与仓库自己的列举比对,模型编造的路径到不了 git) |
| 非 Git 仓库 | 控件不渲染(探测失败即隐藏) |
webServer / workspaceRegistry / agents 缺失 |
host 工具照常挂载,只是没有路由(handler 三个都要用,缺一个就不挂) |
| 会话挂不进工作区 | 报 [attach],checkout 与会话都保留(删掉 checkout 会打断刚在里面启动的会话) |
已知限制
- 不自动清理:会话结束后 worktree 不会自动删除。未提交的工作不该被自动丢弃,删除只能显式发起。
- 不检查 live 会话:删除一个仍有会话在跑的 worktree 目前不做拦截。
- 分支名默认值是
<base>-<6 位随机数字>(没有可记录的 base 时worktree-<6 位随机数字>),不跟随首条消息;随机后缀只有 6 位数字,同一 base 撞名(约百万分之一)时git worktree add会直接报错,再点一次即可。base 分支本身不落盘,但它就在分支名里,所以工作区标题、checkout 目录、git branch都看得见。 - 默认 checkout 在工作区内的
.agents/worktree/下,所以它出现在主 checkout 的git status里(未跟踪目录)。要清静就把.agents/worktree/加进.gitignore/.git/info/exclude,或改用defaultPath: sibling。 - 不能给分支起名:分支在工作区标题里可见,但界面不提供输入。需要指定分支用
worktree_create。 - 胶囊与
选择工作区 / 模式同一行是靠 CSS 对齐的:hero 那一行的两个槽(conversation.hero.workspace、conversation.hero.agentPreset)都是 single 槽,第三方插件没有可注册的座位。控件实际注册在conversation.input.dock(order -10,排在最前),在data-phase='hero'时把这一行压成 0 高度、抵消.composerHero的 8px 行距,再用bottom: 100%贴到上一行右端。因此:非 hero 阶段它仍退回自己的一行(此时本来也不渲染),hero 行距若被上游改动,对齐会差一点;若别的插件往这个 dock 里注册了 order < -10 的条目,控件会贴到那条的上面。 - "开关只在首条消息时才创建"做不到:客户端 composer 的提交是 ui-conversation 内部的输入状态机,槽位里没有"提交前"钩子(
conversation.composer是 chain,选中者只能自己重写整个 composer;conversation.composer.bar是 single,注册进去会把输入框顶掉),Host 侧也只有session/prompt这个 RPC;而SessionHeader.cwd又是创建时冻结的不可变字段。所以"先开关、首条消息再建"需要给 harness 加一个提交前扩展点,插件自身无法实现。当前行为是点击即创建并切过去。
配置
- id: worktree
name: '@guowenzhang/dsh-worktree'
config:
defaultPath: agents # agents(默认,工作区目录下的 .agents/worktree/)| sibling(仓库旁边)
agentsDirectory: .agents/worktree # defaultPath: agents 时的目录,相对工作区;缺的层级会自动创建
createToolName: worktree_create
listToolName: worktree_list
removeToolName: worktree_remove
gitTimeoutMs: 60000
startSessionRoute: true
测试
# 从 Harness checkout 根运行
node_modules/.bin/vitest run --root dsh-worktree
用例覆盖:porcelain 两种分隔格式与 prunable/locked 元数据、分支列表解析、仓库根回溯(linked worktree / submodule 区分)、分支与路径校验、两种默认路径布局与 agentsDirectory 校验、真实 git 上的创建/列举/删除/脏目录/主 checkout 拒绝/从 linked worktree 内再创建/按指定 base 分支新建、路由的原子性与回滚、branches 方法、浏览器侧分支规则与路由客户端、座位的基础分支与分支列表、控件"先拉会话目录、再导航"的次序(含导航被拒后的报错)。
浏览器半边那三个 spec 要 checkout 的 pnpm store 才能解析运行时依赖,走 --config vitest.harness.config.ts 的全量配置:
node_modules/.bin/vitest run --root dsh-worktree --config vitest.harness.config.ts
tsconfig.harness.json 走 checkout 的 paths 解析到源码做类型检查——安装态的各包跨多个发布线,混在一起会得到任何真实部署都不存在的 slot/service 合并结果:
node_modules/.bin/tsc --noEmit -p dsh-worktree/tsconfig.harness.json
构建
npm run build # tsdown(host → lib/index.mjs)+ node build-client.mjs(lib/client.js)
No comments yet. Be the first to write one.