the installer adopts an existing checkout into the managed layout
安装器把已有检出接管进受管布局
`scripts/install.sh` produced two incompatible installation layouts. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/<timestamp>`, and the stable `current` symlink the PATH launcher resolves through. Running the same script from a checkout instead linked `dsh` straight at that checkout's `bin/dsh`, per the earlier [in-repo skip-clone deci
English
Problem
scripts/install.sh produced two incompatible installation layouts. A curl … | sh install built the managed layout — a master clone at ~/.dsh/source/master, a staging worktree on dsh-staging/<timestamp>, and the stable current symlink the PATH launcher resolves through. Running the same script from a checkout instead linked dsh straight at that checkout's bin/dsh, per the earlier in-repo skip-clone decision.
The direct link cannot be upgraded. current is what an upgrade repoints, so an install without it is not upgradable by dsh-upgrade; the PATH symlink dangles if the checkout moves; and the launcher resolves to whatever branch the contributor happened to have checked out, which the upgrade contract forbids as a launcher target. The upgrade skill already described this layout as a legacy install needing a one-time migration, so the layouts diverged at install time and were reconciled only later, if ever.
Decision
In-repo mode still never clones and never modifies the working tree, but it now adopts the checkout into the managed layout unconditionally. There is no opt-out: one layout serves every install.
The container owns staging worktrees and current; the repository is discovered, not owned. git rev-parse --git-common-dir resolves the shared git directory behind the checkout — for a linked worktree that is the real clone rather than the worktree itself — and its parent is the repository that serves as the upgrade base. A staging worktree branched from the checkout's HEAD is then created under $DSH_SOURCE, and current points at it. A clone anywhere on disk therefore converges on the same layout as a curl install, and the two paths share one worktree/exclude/lock/link sequence: they differ only in whether the repository was discovered by git clone or by git rev-parse.
The installer records nothing about where that repository lives. A container whose repository sits outside it is not self-contained — each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them — but git already owns that fact: the worktree's .git file names the path, and git worktree list in the clone enumerates every worktree depending on it.
Adoption branches from HEAD, so committed work is what runs and uncommitted changes stay in the checkout. This is not prompted or warned about: the installer builds the layout and gets out of the way. Setting DSH_SOURCE to a different directory remains the one documented way to opt back into cloning a separate tree.
Every path comparison runs on physical paths through a resolve_dir helper, and every compared value is resolved at assignment rather than at the comparison. Git always reports resolved paths, so comparing one against an unresolved path disagrees whenever a symlink sits anywhere above the checkout — a symlinked home directory is enough, and macOS reaches every mktemp path that way through /var -> private/var. The mismatch misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect class recurs whenever one side of a comparison is left unresolved — as it did with a curl install's REPO_ROOT and with the container path it was compared against. resolve_dir therefore echoes a missing path back rather than failing, so a not-yet-created container needs no per-call fallback and no site can compare against an empty path by forgetting one; callers that need "does not exist" test the directory explicitly. git rev-parse --path-format=absolute would do the same job but requires git 2.31+.
Before current is repointed, the installer rejects a staging path that resolves to the repository itself, enforcing the upgrade contract that the launcher never resolves to the master clone.
Alternatives considered
Make ~/.dsh/source/master a symlink to the arbitrary clone. Rejected. Git resolves the symlink and records the real path: a worktree created through it stores gitdir: …/<clone>/.git/worktrees/<name>, and git worktree list reports the clone. The symlink is therefore decorative — nothing reads it — while implying the container owns the repository. It also fails silently: moving the clone leaves master present but dangling and every staging worktree dead with fatal: not a git repository. Worst, it aliases two names onto one tree, so the "current must never be the master clone" check passes by string comparison while being false. ~/.dsh/source/master is a location, not a name, and only the location is authoritative.
Promote the checkout itself to the current target. Rejected: the upgrade contract requires current to point to a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing.
Keep link-in-place behind a prompt or a DSH_ADOPT flag. Rejected, and an earlier revision of this change shipped exactly that before it was removed. The second layout was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must handle — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a layout nothing should produce. The original motivation for link-in-place, keeping the script testable against local source, survives adoption: a staging worktree branched from the checkout's HEAD runs the same code. DSH_SOURCE remains available for installing a separate tree.
Warn or prompt when the tree is dirty. Rejected: worktree add from HEAD cannot carry uncommitted work, so the behavior is determined and a prompt only adds a decision the user cannot act on differently. The contract is documented instead.
Put an adopted clone's staging worktrees beside the clone (~/src/staging-*) rather than in ~/.dsh/source. Rejected: current and the PATH launcher are per-user singletons, so scattering worktrees across clone parents reintroduces the sibling-clone sprawl the source container exists to prevent.
Consequences
One layout now serves every install, so an adopted clone is upgradable by dsh-upgrade without the one-time migration that skill described, and the installer has no branch that produces an unupgradable layout. In-repo runs still never mutate the working tree.
The cost is that a contributor can no longer point PATH at a checkout and have dsh follow that working tree as they switch branches: the launcher now resolves to a staging worktree pinned to the HEAD adopted at install time. Re-running the installer adopts the current HEAD again.
A container adopting an outside clone is also no longer self-contained: deleting that clone breaks its staging worktrees. This is inherent to reusing an existing clone rather than a property of this design — the rejected symlink hides it rather than fixing it — and git's own worktree records are what diagnose it.
Testing
scripts/install.sh now has a real-shell PTY regression suite in apps/cli/tests/install-script.spec.ts, covering adoption and curl-style paths with stubbed dependencies. Curl-style installs default to the public deepseek-ai/deepseek-harness-sdk source, while replacing the installer with pnpm/npx remains separate work.
Verification was manual, through a throwaway harness driving the real script with a stubbed pnpm: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit DSH_SOURCE still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a curl-style clone install asserting the built layout, the check that catches an unresolved REPO_ROOT. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with dsh running from the new staging worktree while the original checkout keeps its branch and its uncommitted file.
中文
Problem
scripts/install.sh会产生两种互不兼容的安装布局。curl … | sh安装会构建受管布局——~/.dsh/source/master处的 master 克隆、位于dsh-staging/<时间戳>分支上的 staging worktree,以及 PATH 启动器据以解析的稳定current符号链接。而从检出中运行同一脚本时,则依据此前的检出内跳过克隆决策,把dsh直接链接到该检出的bin/dsh。
这种直接链接无法升级。升级重指的正是current,因此缺少它的安装无法通过dsh-upgrade升级;检出一旦移动,PATH 符号链接就会失效;而且启动器会解析到贡献者恰好检出的任意分支,这正是升级约定禁止作为启动器目标的情形。升级技能早已把这种布局描述为需要一次性迁移的旧式安装,因此两种布局从安装时起便不相同,只有以后执行迁移才会一致,而迁移也可能永远不执行。
Decision
检出内模式仍然绝不克隆、绝不修改工作树,但现在它会无条件地把该检出接管进受管布局。不存在退出选项:一套布局服务于所有安装。
容器拥有 staging worktree 和current;仓库是被发现的,而非被拥有的。git rev-parse --git-common-dir会解析出该检出背后的共享 git 目录——对于 linked worktree,那是真正的克隆而非 worktree 自身——其父目录即是充当升级基础的仓库。随后以该检出的HEAD为起点,在$DSH_SOURCE下创建 staging worktree,并让current指向它。因此,磁盘上任意位置的克隆都会收敛到与curl安装相同的布局,且两条路径共用同一套 worktree/exclude/lock/link 流程:二者的唯一差别,只在于仓库是由git clone发现的,还是由git rev-parse发现的。
安装器不会记录该仓库位于何处。仓库位于容器之外时,容器就不是自包含的——每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们——但这一事实本就由 git 自己掌握:worktree 的.git文件写明了该路径,而在该克隆中执行git worktree list会列出依赖于它的每一个 worktree。
接管以HEAD为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把DSH_SOURCE设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。
所有路径比较都通过resolve_dir辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。git 报告的始终是已解析的路径,因此只要检出之上任意一层存在符号链接,拿它与未解析的路径相比较就会不相等——家目录本身是符号链接即已足够,而 macOS 通过/var -> private/var使每个mktemp路径都如此。这种不匹配会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。只要比较的一侧未经解析,同类缺陷就会重现——curl 安装的REPO_ROOT以及与之比较的容器路径都曾如此。因此resolve_dir在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。git rev-parse --path-format=absolute能完成同样的工作,但要求 git 2.31 及以上版本。
在重指current之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级约定。
Alternatives considered
把~/.dsh/source/master做成指向该任意克隆的符号链接。 已否决。Git 会解析该符号链接并记录真实路径:经由它创建的 worktree 会存储gitdir: …/<克隆>/.git/worktrees/<名称>,而git worktree list报告的是该克隆。因此这个符号链接纯属装饰——没有任何代码读取它——却又暗示容器拥有该仓库。它还会静默失效:移动克隆后,master看似仍在却已悬空,而每个 staging worktree 都会以fatal: not a git repository失败。最糟的是,它把两个名称别名到同一棵树上,于是"current 绝不能是 master 克隆"这项检查会在字符串比较下通过,实则为假。~/.dsh/source/master是位置而非名称,且只有位置具有权威性。
把检出自身提升为current的目标。 已否决:升级约定要求current指向 staging 分支上的干净 staging worktree,绝不能指向 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。
把就地链接保留在提示或DSH_ADOPT开关之后。 已否决;本次变更的早期修订版本正是如此实现,之后被移除。第二种布局本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动必须处理的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的布局而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的HEAD为起点创建的 staging worktree 运行的是同一份代码。DSH_SOURCE仍可用于安装另一棵树。
在工作树不干净时发出警告或提示。 已否决:以HEAD为起点的worktree add本就无法带上未提交的内容,因此该行为是确定的,提示只会增加一个用户无法做出不同选择的决策点。改为在文档中说明该约定。
把被接管克隆的 staging worktree 放在该克隆旁边(~/src/staging-*),而非放进~/.dsh/source。已否决:current和 PATH 启动器都是每用户唯一的,因此把 worktree 散落到各个克隆的父目录中,会重新引入 source 容器本就为之而设、意在杜绝的同级克隆蔓延问题。
Consequences
现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由dsh-upgrade升级,而且安装器不再有任何一条分支会产生无法升级的布局。检出内运行仍然绝不改动工作树。
代价是:贡献者不能再把 PATH 指向某个检出、并让dsh随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管HEAD上的 staging worktree。重新运行安装器会再次接管当前的HEAD。
此外,接管外部克隆的容器不再自包含:删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——诊断依据则是 git 自身的 worktree 记录。
Testing
scripts/install.sh 现有一套位于 apps/cli/tests/install-script.spec.ts 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。curl 风格安装默认使用公开的 deepseek-ai/deepseek-harness-sdk 源,而以 pnpm/npx 替换安装器仍是另一项工作。
验证是手工完成的,通过一个一次性测试装置以打桩的pnpm驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式DSH_SOURCE仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及curl式克隆安装断言所构建的布局——该检查能捕获未解析的REPO_ROOT。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终dsh从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。