From 631510f54e2f01d0ab5b173b46518f6766a94777 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 20:22:40 +0800 Subject: [PATCH 01/11] feat(install): adopt an existing checkout into the managed layout Running scripts/install.sh from a checkout linked `dsh` straight at that checkout, producing an install that `dsh-upgrade` cannot upgrade (there is no `current` to repoint), that dangles if the checkout moves, and whose launcher resolves to an arbitrary working branch. In-repo mode still never clones and never touches the working tree, but it now offers to adopt the checkout, and adoption is the default. The container owns staging worktrees and `current`; the repository is discovered via `git rev-parse --git-common-dir` rather than owned, so a clone anywhere on disk converges on the same upgradable layout as a curl install and both share one worktree/exclude/lock/link sequence. Declining, or DSH_ADOPT=0, keeps the previous link-in-place behavior with a warning naming what it costs, preserving the path that makes this script testable against local source. All path comparisons run on physical paths: macOS resolves /var through a symlink to /private/var, and comparing a resolved path against an unresolved one misclassified an existing managed install as a foreign clone. Verified manually (no install.spec.ts, per request) with a harness driving the real script under a stubbed pnpm across 33 assertions, plus both interactive outcomes under tmux. --- ...staller-adopts-existing-checkout.i18n.yaml | 6 + ...7-31-installer-adopts-existing-checkout.md | 49 ++++ ...1-installer-adopts-existing-checkout.zh.md | 49 ++++ README.i18n.yaml | 4 +- README.md | 2 + README.zh.md | 2 + scripts/install.sh | 224 ++++++++++++++---- 7 files changed, 285 insertions(+), 51 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md create mode 100644 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml new file mode 100644 index 0000000000..0aab25dae7 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +2026-07-31-installer-adopts-existing-checkout.md: 2513b31410d045469507b49176236bacb138ff1e +2026-07-31-installer-adopts-existing-checkout.zh.md: 3fca61e83730bf30bdb84c8832c2fd2163723858 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md new file mode 100644 index 0000000000..2513b31410 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -0,0 +1,49 @@ +# Agent Note: the installer adopts an existing checkout into the managed layout + +Status: implemented + +English | [中文](2026-07-31-installer-adopts-existing-checkout.zh.md) + +## Problem + +`scripts/install.sh` produced two incompatible install shapes. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/`, 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](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md). + +The direct link is a terminal state. `current` is what an upgrade repoints, so an install without it is not upgradable by [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md); 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 shape 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 offers to **adopt** the checkout into the managed layout, and adoption is the default. + +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`. + +`$DSH_SOURCE/master.path` records the resolved repository, and only when that repository lives outside the container. A container holding its own master is self-contained and gets no file, so the file's presence is itself the signal that this container depends on an outside path: each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them. + +Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout; a dirty tree is warned about before the prompt and whenever `DSH_ADOPT=1` skips it. Declining, or `DSH_ADOPT=0`, keeps the previous link-in-place behavior with a warning naming what it costs, because that path is what makes this script testable against local source. A repository with no commits cannot be branched and falls back to link-in-place; a checkout that is not a git repository fails with the `DSH_ADOPT=0` escape hatch named. + +`DSH_ADOPT=1` also overrides the rule that an explicit `DSH_SOURCE` opts back into cloning. Naming a container while asking for adoption otherwise silently cloned a different tree — the opposite of the request. + +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. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and wrote a spurious `master.path`, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and 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: …//.git/worktrees/`, 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 be 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 adoption opt-in.** Rejected as the default: the divergent shape was the actual defect, and leaving the fix behind a flag means the common `sh scripts/install.sh` invocation keeps producing unupgradable installs. Declining is one keystroke and `DSH_ADOPT=0` is scriptable. + +**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 both installs, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described. In-repo runs still never mutate the working tree, and the escape hatch that keeps this script testable against local source survives behind a prompt and `DSH_ADOPT=0`. + +The cost is that a container adopting an outside clone is 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 `master.path` is the mitigation, not a repair. + +## Testing + +`scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. + +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; `DSH_ADOPT=0` preserving link-in-place; a commitless repository falling back; a dirty tree warning while leaving uncommitted work behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting both the built layout and the absence of `master.path`, which is the regression that caught the unresolved-`REPO_ROOT` defect. Both interactive outcomes were exercised under tmux: accepting ends with the launcher running from the new staging worktree while the original checkout keeps its branch and clean status, and declining reproduces the legacy shape with no staging worktree and no `current`. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md new file mode 100644 index 0000000000..3fca61e837 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -0,0 +1,49 @@ +# Agent Note: 安装器把已有检出接管进受管布局 + +Status: implemented + +[English](2026-07-31-installer-adopts-existing-checkout.md) | 中文 + +## Problem + +`scripts/install.sh`会产生两种互不兼容的安装形态。`curl … | sh`安装会构建受管布局——`~/.dsh/source/master`处的 master 克隆、位于`dsh-staging/<时间戳>`分支上的 staging worktree,以及 PATH 启动器据以解析的稳定`current`符号链接。而从检出中运行同一脚本时,则依据此前的[检出内跳过克隆决策](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md),把`dsh`直接链接到该检出的`bin/dsh`。 + +这种直接链接是一种终态。升级重指的正是`current`,因此缺少它的安装无法通过[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md)升级;检出一旦移动,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`发现的。 + +`$DSH_SOURCE/master.path`记录解析出的仓库,且仅在该仓库位于容器之外时才记录。拥有自身 master 的容器是自包含的,不会生成该文件;因此该文件的存在本身就是一个信号,表明此容器依赖于外部路径:每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们。 + +接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中;工作树不干净时,会在提示前发出警告,`DSH_ADOPT=1`跳过提示时同样警告。拒绝接管或设置`DSH_ADOPT=0`将保留原有的就地链接行为,并以警告说明其代价,因为正是这条路径使本脚本能针对本地源码进行测试。没有任何提交的仓库无法创建分支,会回退到就地链接;并非 git 仓库的检出则会失败,并在错误信息中给出`DSH_ADOPT=0`这一退路。 + +`DSH_ADOPT=1`同时会覆盖"显式`DSH_SOURCE`即回到克隆路径"的规则。否则,在请求接管的同时指定容器,反而会静默克隆另一棵树——与请求恰好相反。 + +所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,导致写出多余的`master.path`;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`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 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 + +**让接管保持为可选项。** 作为默认行为已否决:分叉的形态本身才是真正的缺陷,把修复藏在开关之后,意味着常见的`sh scripts/install.sh`调用仍会产生无法升级的安装。拒绝只需一次按键,而`DSH_ADOPT=0`可用于脚本。 + +**把被接管克隆的 staging worktree 放在该克隆旁边**(`~/src/staging-*`),而非放进`~/.dsh/source`。已否决:`current`和 PATH 启动器都是每用户唯一的,因此把 worktree 散落到各个克隆的父目录中,会重新引入 source 容器本就为之而设、意在杜绝的同级克隆蔓延问题。 + +## Consequences + +现在一套布局同时服务于两种安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级。检出内运行仍然绝不改动工作树,而使本脚本能针对本地源码进行测试的那条退路,也以提示和`DSH_ADOPT=0`的形式保留了下来。 + +代价是:接管外部克隆的容器不再自包含——删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——`master.path`是缓解措施,不是修复。 + +## Testing + +`scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 + +验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;`DSH_ADOPT=0`保持就地链接;无提交的仓库发生回退;工作树不干净时发出警告并把未提交内容留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装同时断言所构建的布局和`master.path`的缺失——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。两种交互结果都在 tmux 下走通:接受时,启动器最终从新的 staging worktree 运行,而原检出保持其分支不变且状态干净;拒绝时,则复现旧式形态,既无 staging worktree 也无`current`。 diff --git a/README.i18n.yaml b/README.i18n.yaml index b492ed9c37..07b5f5c2b2 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: b447c9634189353854e8be9d0bf597a8b0c7e371 -README.zh.md: f8bbbc36bc670403c0b9a40977f32f598e77ee46 +README.md: 6266e9087e6cc6f53e61127559e166064cbd3970 +README.zh.md: 1343c099161b660d9d23d101a92deca1d35e441b diff --git a/README.md b/README.md index b447c96341..6266e9087e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. +Running the script from an existing clone (`sh scripts/install.sh`) never clones and never modifies that working tree. It offers to *adopt* the clone: the repository behind the checkout becomes the upgrade base, and a staging worktree branched from the checkout's current `HEAD` lands under `~/.dsh/source` with `current` pointing at it, so a clone anywhere on disk gets the same upgradable layout. Adoption carries committed work only — uncommitted changes stay in the clone. Declining (or `DSH_ADOPT=0`) links `dsh` straight at that checkout instead, which is not upgradable and breaks if the checkout moves. + ## Use DeepSeek Harness ### Web UI diff --git a/README.zh.md b/README.zh.md index f8bbbc36bc..1343c09916 100644 --- a/README.zh.md +++ b/README.zh.md @@ -26,6 +26,8 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m 安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 +从现有克隆中运行该脚本(`sh scripts/install.sh`)不会进行任何克隆,也不会修改该工作树。它会询问是否*接管*该克隆:该检出所属的仓库将成为升级基础,以该检出当前的 `HEAD` 为起点创建的 staging worktree 会被放在 `~/.dsh/source` 下,并由 `current` 指向它,因此磁盘上任意位置的克隆都能获得相同的可升级布局。接管只会带入已提交的内容——未提交的更改仍留在克隆中。如果拒绝接管(或设置 `DSH_ADOPT=0`),则会改为将 `dsh` 直接链接到该检出;这种方式无法升级,且检出一旦移动,链接就会失效。 + ## 使用 DeepSeek Harness ### Web UI diff --git a/scripts/install.sh b/scripts/install.sh index 41d5c749c1..5c38f8974c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -18,12 +18,27 @@ # of relinking PATH: the `dsh` on PATH never moves and can never dangle. # # When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather -# than `curl ... | sh`) it reuses that checkout in place and skips the -# clone/worktree setup, leaving the working tree untouched and linking `dsh` -# straight at that checkout's `bin/dsh` (no `current` indirection — the checkout -# is not a managed staging worktree under the source container); DSH_REF is -# ignored in that mode. Setting DSH_SOURCE to a different directory opts back -# into the normal clone/worktree path. +# than `curl ... | sh`) it never clones and never touches that working tree; +# DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse +# --git-common-dir` resolves the repository behind it (for a linked worktree that +# is the real clone, not the worktree), and a fresh staging worktree branched +# from the checkout's HEAD lands in the source container beside `current`. The +# container owns staging worktrees and `current`; the clone is discovered, not +# owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one +# layout and stay upgradable. Adoption carries committed work only — uncommitted +# changes stay in the checkout — so a dirty tree is confirmed first. +# +# Declining adoption (or DSH_ADOPT=0) keeps the legacy behavior: link `dsh` +# straight at that checkout's `bin/dsh` with no `current` indirection. That +# leaves the install unupgradable (`current` is what an upgrade repoints) and the +# PATH symlink dangling if the checkout moves, but it is what makes this script +# testable against local source. Setting DSH_SOURCE to a different directory opts +# back into the normal clone/worktree path. +# +# Adopting an arbitrary clone leaves the container not self-contained: its +# staging worktrees hold an absolute gitdir pointer into that clone, so deleting +# it breaks them. $DSH_SOURCE/master.path records the resolved clone so the +# breakage is diagnosable. # # When run through `curl | sh` the script text arrives on stdin, so every # prompt and the final launch read the controlling terminal (/dev/tty) directly; @@ -37,6 +52,8 @@ # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding the personal config (default: ~/.dsh) +# DSH_ADOPT in-repo mode: 1 adopts the checkout into the managed +# layout, 0 links `dsh` straight at it (default: ask, adopt) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript # entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu @@ -60,26 +77,43 @@ DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ) DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP +# --- path helpers --------------------------------------------------------------- +# Every path comparison below runs on physical paths. macOS resolves /var through +# a symlink to /private/var, so comparing a git-reported (already resolved) path +# against an unresolved one silently misclassifies an existing managed install as +# a foreign clone and builds a second container beside the real one. +# `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+. +# +# A not-yet-created directory (the container on a fresh install) has no physical +# path, so fall back to the literal argument here rather than at each call site: +# `x=$(cmd) || fallback` never fires, because the assignment succeeds even when +# the substitution fails, which would silently yield an empty path. +resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; } + # --- in-repo detection --------------------------------------------------------- # Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell # name and no file path resolves; running a checked-out copy (`sh # scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose # parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), -# reuse that checkout in place — link `dsh` straight at it and skip the -# clone/worktree setup. An explicit DSH_SOURCE pointing elsewhere opts back into -# the clone/worktree path. +# this is in-repo mode: never clone, never touch that working tree. An explicit +# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path, unless +# DSH_ADOPT=1 asks to adopt this checkout into that container — otherwise naming +# a container while requesting adoption would silently clone a different tree. IN_REPO=0 +DSH_CHECKOUT='' if [ -f "$0" ]; then - _self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir='' + _self_dir=$(resolve_dir "$(dirname -- "$0")") if [ -n "$_self_dir" ]; then _repo_root=$(dirname -- "$_self_dir") if [ "$(basename -- "$_self_dir")" = scripts ] \ && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then - if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then + # Compare the explicit DSH_SOURCE physically: an unresolved but equivalent + # path must still count as "the caller meant this checkout". + _src_resolved=$(resolve_dir "$DSH_SOURCE") + if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ] \ + || [ "${DSH_ADOPT:-}" = 1 ]; then IN_REPO=1 - # In-repo reuse links `dsh` at this checkout as-is; the master/staging - # split applies only to fresh clone installs. - DSH_STAGING=$_repo_root + DSH_CHECKOUT=$_repo_root fi fi fi @@ -148,7 +182,7 @@ confirm() { printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}" if [ "$IN_REPO" = 1 ]; then - printf '%ssource %s (in-repo reuse) @ %s%s\n' "$DIM" "$DSH_STAGING" "$DSH_REF" "$RST" + printf '%scheckout %s%s\n' "$DIM" "$DSH_CHECKOUT" "$RST" else printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST" printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST" @@ -203,41 +237,125 @@ else fi fi -# --- 2. clone the master and lay out the staging worktree --------------------- -# Fresh installs keep one real clone at $DSH_MASTER and check the running code -# out as a git worktree at $DSH_STAGING, so every checkout lives under -# $DSH_SOURCE and shares one object store. In-repo reuse links `dsh` at the -# existing checkout untouched. +# --- 2. resolve the repository and lay out the staging worktree --------------- +# The source container owns staging worktrees and `current`; the repository is +# *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER; +# in-repo adoption discovers it from the checkout. Both then run one shared +# worktree/exclude/lock path, so an arbitrary clone and a managed install +# converge on the same layout. +# +# ADOPT=1 means "build the managed layout" (clone install, or in-repo adoption); +# ADOPT=0 is in-repo legacy reuse, which links `dsh` at the checkout as-is. +ADOPT=1 +# REPO_COMMON is the shared git directory every worktree of the repository +# points at; REPO_ROOT is the working tree that owns it (the master clone). +REPO_COMMON='' +REPO_ROOT='' + if [ "$IN_REPO" = 1 ]; then - step "Using existing checkout at $DSH_STAGING" - info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)" + step "Using existing checkout at $DSH_CHECKOUT" + info "running from inside the repo — never cloning, and DSH_REF is ignored" + + # Resolve the repository behind the checkout. --git-common-dir returns the + # SHARED git dir, so a linked worktree resolves to the real clone rather than + # itself; it is relative for a plain clone, so anchor it before resolving. + # Require the resolved git dir to exist: resolve_dir echoes its argument back + # for a missing path, so test the directory rather than the returned string. + if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then + case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac + [ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common") + fi + [ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it. Re-run with DSH_ADOPT=0 to link dsh at it as-is." + REPO_ROOT=$(dirname -- "$REPO_COMMON") + + # A repository with no commit cannot be branched, so adoption is impossible. + if ! git -C "$DSH_CHECKOUT" rev-parse --verify -q HEAD >/dev/null 2>&1; then + warn "checkout has no commits — cannot create a staging branch; linking dsh at it as-is." + ADOPT=0 + fi + + # Explicit DSH_ADOPT wins over the prompt in both directions. + if [ "${DSH_ADOPT:-}" = 0 ]; then + ADOPT=0 + elif [ "$ADOPT" = 1 ]; then + # Adoption branches from HEAD, so uncommitted work stays behind in the + # checkout and is NOT part of the install that ends up running. Warn even + # when DSH_ADOPT=1 skips the prompt: the surprise is the same either way. + if [ -n "$(git -C "$DSH_CHECKOUT" status --porcelain 2>/dev/null)" ]; then + warn "checkout has uncommitted changes; adoption branches from HEAD, so they stay here and will not be in the running install." + fi + fi + if [ "$ADOPT" = 1 ] && [ "${DSH_ADOPT:-}" != 1 ]; then + printf '%s\n' "${DIM}Adopting builds the managed layout under $DSH_SOURCE (staging worktree + current symlink) so this install stays upgradable.${RST}" + printf '%s\n' "${DIM}Declining links dsh straight at this checkout: not upgradable, and the PATH symlink breaks if the checkout moves.${RST}" + confirm "Adopt this checkout into the managed layout?" Y || ADOPT=0 + fi + + if [ "$ADOPT" = 0 ]; then + info "linking dsh at this checkout as-is (legacy in-repo reuse)" + DSH_STAGING=$DSH_CHECKOUT + else + # Reuse the container when the repository already lives inside it (the + # normal managed install re-running its own script); otherwise treat that + # clone as its own master and keep worktrees in the default container. + _src_resolved=$(resolve_dir "$DSH_SOURCE") + case "$REPO_ROOT/" in + "$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;; + *) info "adopting clone $REPO_ROOT as its own master" ;; + esac + DSH_MASTER=$REPO_ROOT + fi else -step "Fetching source into $DSH_MASTER" -if [ -d "$DSH_MASTER/.git" ]; then - info "existing master clone found — updating" - git -C "$DSH_MASTER" fetch origin "$DSH_REF" - # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not - # origin/) so this resolves for a tag as well as a branch, and -B makes - # the re-run idempotent whether or not DSH_REF changed since the last install. - git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD -else - mkdir -p "$DSH_SOURCE" - git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" + step "Fetching source into $DSH_MASTER" + if [ -d "$DSH_MASTER/.git" ]; then + info "existing master clone found — updating" + git -C "$DSH_MASTER" fetch origin "$DSH_REF" + # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not + # origin/) so this resolves for a tag as well as a branch, and -B makes + # the re-run idempotent whether or not DSH_REF changed since the last install. + git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD + else + mkdir -p "$DSH_SOURCE" + git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" + fi + REPO_COMMON=$DSH_MASTER/.git + # Physical, to match the adoption branch: every REPO_ROOT comparison below + # runs against resolved paths. + REPO_ROOT=$(resolve_dir "$DSH_MASTER") fi -step "Adding staging worktree at $DSH_STAGING" -[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." -# The staging worktree owns the branch dsh runs from; the master clone stays on -# $DSH_REF as the fetch/upgrade base. Exclude the per-worktree merge lock in the -# master clone's info/exclude, which every linked worktree inherits. -git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ - || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD -_exclude="$DSH_MASTER/.git/info/exclude" -if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then - printf '.agents/merge.lock\n' >>"$_exclude" -fi -mkdir -p "$DSH_STAGING/.agents" -: >"$DSH_STAGING/.agents/merge.lock" +if [ "$ADOPT" = 1 ]; then + step "Adding staging worktree at $DSH_STAGING" + [ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." + mkdir -p "$DSH_SOURCE" + # The staging worktree owns the branch dsh runs from; the repository stays as + # the fetch/upgrade base and is never a launcher target. A clone install + # branches from the ref it just fetched; adoption branches from the checkout's + # HEAD so the contributor's committed work is what runs. + if [ "$IN_REPO" = 1 ]; then + git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD + else + git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ + || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD + fi + # Exclude the per-worktree merge lock in the shared git dir's info/exclude, + # which every linked worktree inherits. + _exclude="$REPO_COMMON/info/exclude" + if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then + printf '.agents/merge.lock\n' >>"$_exclude" + fi + mkdir -p "$DSH_STAGING/.agents" + : >"$DSH_STAGING/.agents/merge.lock" + # A staging worktree holds an absolute gitdir pointer into the repository, so + # a container whose repository lives OUTSIDE it is not self-contained: deleting + # that repository breaks every worktree here. Record it only in that case, so + # the file's presence itself means "this container depends on an outside path". + _src_resolved=$(resolve_dir "$DSH_SOURCE") + case "$REPO_ROOT/" in + "$_src_resolved"/*) ;; + *) printf '%s\n' "$REPO_ROOT" >"$DSH_SOURCE/master.path" + info "recorded external repository in $DSH_SOURCE/master.path" ;; + esac fi # --- 3. install dependencies (no build; the launcher runs from source) -------- @@ -247,16 +365,17 @@ step "Installing dependencies with pnpm (this can take a while)" [ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" # --- 4. put `dsh` on PATH ------------------------------------------------------ -# Clone installs go through a stable `current` symlink so an upgrade repoints +# Managed installs go through a stable `current` symlink so an upgrade repoints # one symlink (current -> new worktree) and the PATH launcher never moves: -# PATH/dsh -> current/bin/dsh -> /bin/dsh. In-repo reuse links PATH +# PATH/dsh -> current/bin/dsh -> /bin/dsh. Declined adoption links PATH # straight at the checkout, since that checkout is not a managed worktree. step "Linking dsh into $DSH_BIN_DIR" mkdir -p "$DSH_BIN_DIR" -if [ "$IN_REPO" = 1 ]; then +if [ "$ADOPT" = 0 ]; then DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" + warn "this install is not upgradable (no current symlink) and the PATH link breaks if $DSH_STAGING moves." else # Point `current` at this staging worktree with `ln -sfn`: -f replaces an # existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing @@ -264,6 +383,13 @@ else # worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir # symlink the same way. The swap is one unlink+symlink pair on a local fs; the # installer holds no other process racing this path. + # The launcher must resolve to a staging worktree, never to the repository + # itself: an upgrade repoints `current`, so aliasing it onto the master clone + # would make every upgrade rewrite the fetch/upgrade base. Compare physical + # paths — a symlinked or unresolved path would slip past a string compare. + _staging_resolved=$(resolve_dir "$DSH_STAGING") + [ -n "$REPO_ROOT" ] && [ "$_staging_resolved" = "$REPO_ROOT" ] \ + && die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree." ln -sfn "$DSH_STAGING" "$DSH_CURRENT" info "pointed $DSH_CURRENT -> $DSH_STAGING" DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh From 00c5f2abd1922a25d177941734c028790426ca6d Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 20:31:30 +0800 Subject: [PATCH 02/11] refactor(install): always adopt, dropping the link-in-place path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retaining link-in-place behind a prompt and DSH_ADOPT kept the divergent install shape that this change exists to remove, and cost a flag, a prompt, a dirty-tree warning, a no-commit fallback, and a second linking path. In-repo mode now adopts unconditionally. A dirty tree adopts silently: `worktree add` from HEAD cannot carry uncommitted work, so a prompt only adds a decision the user cannot act on differently. The original reason for link-in-place — keeping the script testable against local source — survives adoption, since the staging worktree branches from the checkout's HEAD and runs the same code. DSH_SOURCE remains the escape hatch for installing a separate tree. Net 47 fewer lines in the installer. --- ...staller-adopts-existing-checkout.i18n.yaml | 4 +- ...7-31-installer-adopts-existing-checkout.md | 16 +- ...1-installer-adopts-existing-checkout.zh.md | 16 +- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- scripts/install.sh | 183 +++++++----------- 7 files changed, 90 insertions(+), 137 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 0aab25dae7..c4689a5f4c 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 2513b31410d045469507b49176236bacb138ff1e -2026-07-31-installer-adopts-existing-checkout.zh.md: 3fca61e83730bf30bdb84c8832c2fd2163723858 +2026-07-31-installer-adopts-existing-checkout.md: 75f71d1dc7f7d84674c7f11ec1affe616acbd0b2 +2026-07-31-installer-adopts-existing-checkout.zh.md: 381a3c0f67aa20198caa25b558f6d6d1b5591413 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index 2513b31410..75f71d1dc7 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -12,15 +12,13 @@ The direct link is a terminal state. `current` is what an upgrade repoints, so a ## Decision -In-repo mode still never clones and never modifies the working tree, but it now offers to **adopt** the checkout into the managed layout, and adoption is the default. +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`. `$DSH_SOURCE/master.path` records the resolved repository, and only when that repository lives outside the container. A container holding its own master is self-contained and gets no file, so the file's presence is itself the signal that this container depends on an outside path: each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them. -Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout; a dirty tree is warned about before the prompt and whenever `DSH_ADOPT=1` skips it. Declining, or `DSH_ADOPT=0`, keeps the previous link-in-place behavior with a warning naming what it costs, because that path is what makes this script testable against local source. A repository with no commits cannot be branched and falls back to link-in-place; a checkout that is not a git repository fails with the `DSH_ADOPT=0` escape hatch named. - -`DSH_ADOPT=1` also overrides the rule that an explicit `DSH_SOURCE` opts back into cloning. Naming a container while asking for adoption otherwise silently cloned a different tree — the opposite of the request. +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. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and wrote a spurious `master.path`, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and 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+. @@ -32,13 +30,17 @@ Before `current` is repointed, the installer rejects a staging path that resolve **Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to be 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 adoption opt-in.** Rejected as the default: the divergent shape was the actual defect, and leaving the fix behind a flag means the common `sh scripts/install.sh` invocation keeps producing unupgradable installs. Declining is one keystroke and `DSH_ADOPT=0` is scriptable. +**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 divergent shape was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must reason about — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a shape 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 the escape hatch 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 both installs, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described. In-repo runs still never mutate the working tree, and the escape hatch that keeps this script testable against local source survives behind a prompt and `DSH_ADOPT=0`. +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 shape. 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. The cost is that a container adopting an outside clone is 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 `master.path` is the mitigation, not a repair. @@ -46,4 +48,4 @@ The cost is that a container adopting an outside clone is no longer self-contain `scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. -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; `DSH_ADOPT=0` preserving link-in-place; a commitless repository falling back; a dirty tree warning while leaving uncommitted work behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting both the built layout and the absence of `master.path`, which is the regression that caught the unresolved-`REPO_ROOT` defect. Both interactive outcomes were exercised under tmux: accepting ends with the launcher running from the new staging worktree while the original checkout keeps its branch and clean status, and declining reproduces the legacy shape with no staging worktree and no `current`. +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 both the built layout and the absence of `master.path`, which is the regression that caught the unresolved-`REPO_ROOT` defect. 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. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 3fca61e837..381a3c0f67 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -12,15 +12,13 @@ Status: implemented ## 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`发现的。 `$DSH_SOURCE/master.path`记录解析出的仓库,且仅在该仓库位于容器之外时才记录。拥有自身 master 的容器是自包含的,不会生成该文件;因此该文件的存在本身就是一个信号,表明此容器依赖于外部路径:每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们。 -接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中;工作树不干净时,会在提示前发出警告,`DSH_ADOPT=1`跳过提示时同样警告。拒绝接管或设置`DSH_ADOPT=0`将保留原有的就地链接行为,并以警告说明其代价,因为正是这条路径使本脚本能针对本地源码进行测试。没有任何提交的仓库无法创建分支,会回退到就地链接;并非 git 仓库的检出则会失败,并在错误信息中给出`DSH_ADOPT=0`这一退路。 - -`DSH_ADOPT=1`同时会覆盖"显式`DSH_SOURCE`即回到克隆路径"的规则。否则,在请求接管的同时指定容器,反而会静默克隆另一棵树——与请求恰好相反。 +接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,导致写出多余的`master.path`;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`resolve_dir`会在路径不存在时原样回显该路径,而需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 @@ -32,13 +30,17 @@ Status: implemented **把检出自身提升为`current`的目标。** 已否决:升级契约要求`current`必须是位于 staging 分支上的干净 staging worktree,绝不能是 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 -**让接管保持为可选项。** 作为默认行为已否决:分叉的形态本身才是真正的缺陷,把修复藏在开关之后,意味着常见的`sh scripts/install.sh`调用仍会产生无法升级的安装。拒绝只需一次按键,而`DSH_ADOPT=0`可用于脚本。 +**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。分叉的形态本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动需要推敲的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的形态而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍是安装另一棵树的退路。 + +**在工作树不干净时发出警告或提示。** 已否决:以`HEAD`为起点的`worktree add`本就无法带上未提交的内容,因此该行为是确定的,提示只会增加一个用户无法做出不同选择的决策点。改为在文档中说明该契约。 **把被接管克隆的 staging worktree 放在该克隆旁边**(`~/src/staging-*`),而非放进`~/.dsh/source`。已否决:`current`和 PATH 启动器都是每用户唯一的,因此把 worktree 散落到各个克隆的父目录中,会重新引入 source 容器本就为之而设、意在杜绝的同级克隆蔓延问题。 ## Consequences -现在一套布局同时服务于两种安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级。检出内运行仍然绝不改动工作树,而使本脚本能针对本地源码进行测试的那条退路,也以提示和`DSH_ADOPT=0`的形式保留了下来。 +现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的形态。检出内运行仍然绝不改动工作树。 + +代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`。 代价是:接管外部克隆的容器不再自包含——删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——`master.path`是缓解措施,不是修复。 @@ -46,4 +48,4 @@ Status: implemented `scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 -验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;`DSH_ADOPT=0`保持就地链接;无提交的仓库发生回退;工作树不干净时发出警告并把未提交内容留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装同时断言所构建的布局和`master.path`的缺失——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。两种交互结果都在 tmux 下走通:接受时,启动器最终从新的 staging worktree 运行,而原检出保持其分支不变且状态干净;拒绝时,则复现旧式形态,既无 staging worktree 也无`current`。 +验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装同时断言所构建的布局和`master.path`的缺失——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 07b5f5c2b2..cb06123784 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: 6266e9087e6cc6f53e61127559e166064cbd3970 -README.zh.md: 1343c099161b660d9d23d101a92deca1d35e441b +README.md: f7c563bb9ade47890dbc24b23af67ef663dc92fd +README.zh.md: 49c4acca0853346d95ab9de949dc26b11552ddcd diff --git a/README.md b/README.md index 6266e9087e..f7c563bb9a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. -Running the script from an existing clone (`sh scripts/install.sh`) never clones and never modifies that working tree. It offers to *adopt* the clone: the repository behind the checkout becomes the upgrade base, and a staging worktree branched from the checkout's current `HEAD` lands under `~/.dsh/source` with `current` pointing at it, so a clone anywhere on disk gets the same upgradable layout. Adoption carries committed work only — uncommitted changes stay in the clone. Declining (or `DSH_ADOPT=0`) links `dsh` straight at that checkout instead, which is not upgradable and breaks if the checkout moves. +Running the script from an existing clone (`sh scripts/install.sh`) never clones and never modifies that working tree. It *adopts* the clone: the repository behind the checkout becomes the upgrade base, and a staging worktree branched from the checkout's current `HEAD` lands under `~/.dsh/source` with `current` pointing at it, so a clone anywhere on disk gets the same upgradable layout. Adoption carries committed work only — uncommitted changes stay in the clone. ## Use DeepSeek Harness diff --git a/README.zh.md b/README.zh.md index 1343c09916..49c4acca08 100644 --- a/README.zh.md +++ b/README.zh.md @@ -26,7 +26,7 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m 安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 -从现有克隆中运行该脚本(`sh scripts/install.sh`)不会进行任何克隆,也不会修改该工作树。它会询问是否*接管*该克隆:该检出所属的仓库将成为升级基础,以该检出当前的 `HEAD` 为起点创建的 staging worktree 会被放在 `~/.dsh/source` 下,并由 `current` 指向它,因此磁盘上任意位置的克隆都能获得相同的可升级布局。接管只会带入已提交的内容——未提交的更改仍留在克隆中。如果拒绝接管(或设置 `DSH_ADOPT=0`),则会改为将 `dsh` 直接链接到该检出;这种方式无法升级,且检出一旦移动,链接就会失效。 +从现有克隆中运行该脚本(`sh scripts/install.sh`)不会进行任何克隆,也不会修改该工作树。它会*接管*该克隆:该检出所属的仓库将成为升级基础,以该检出当前的 `HEAD` 为起点创建的 staging worktree 会被放在 `~/.dsh/source` 下,并由 `current` 指向它,因此磁盘上任意位置的克隆都能获得相同的可升级布局。接管只会带入已提交的内容——未提交的更改仍留在克隆中。 ## 使用 DeepSeek Harness diff --git a/scripts/install.sh b/scripts/install.sh index 5c38f8974c..b26beccf2c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -25,15 +25,10 @@ # from the checkout's HEAD lands in the source container beside `current`. The # container owns staging worktrees and `current`; the clone is discovered, not # owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one -# layout and stay upgradable. Adoption carries committed work only — uncommitted -# changes stay in the checkout — so a dirty tree is confirmed first. -# -# Declining adoption (or DSH_ADOPT=0) keeps the legacy behavior: link `dsh` -# straight at that checkout's `bin/dsh` with no `current` indirection. That -# leaves the install unupgradable (`current` is what an upgrade repoints) and the -# PATH symlink dangling if the checkout moves, but it is what makes this script -# testable against local source. Setting DSH_SOURCE to a different directory opts -# back into the normal clone/worktree path. +# layout and stay upgradable. Adoption carries committed work only: the staging +# worktree branches from HEAD, so uncommitted changes stay in the checkout. +# Setting DSH_SOURCE to a different directory opts back into the normal +# clone/worktree path. # # Adopting an arbitrary clone leaves the container not self-contained: its # staging worktrees hold an absolute gitdir pointer into that clone, so deleting @@ -52,8 +47,6 @@ # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding the personal config (default: ~/.dsh) -# DSH_ADOPT in-repo mode: 1 adopts the checkout into the managed -# layout, 0 links `dsh` straight at it (default: ask, adopt) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript # entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu @@ -96,9 +89,7 @@ resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; # scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose # parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), # this is in-repo mode: never clone, never touch that working tree. An explicit -# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path, unless -# DSH_ADOPT=1 asks to adopt this checkout into that container — otherwise naming -# a container while requesting adoption would silently clone a different tree. +# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path. IN_REPO=0 DSH_CHECKOUT='' if [ -f "$0" ]; then @@ -110,8 +101,7 @@ if [ -f "$0" ]; then # Compare the explicit DSH_SOURCE physically: an unresolved but equivalent # path must still count as "the caller meant this checkout". _src_resolved=$(resolve_dir "$DSH_SOURCE") - if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ] \ - || [ "${DSH_ADOPT:-}" = 1 ]; then + if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then IN_REPO=1 DSH_CHECKOUT=$_repo_root fi @@ -244,9 +234,6 @@ fi # worktree/exclude/lock path, so an arbitrary clone and a managed install # converge on the same layout. # -# ADOPT=1 means "build the managed layout" (clone install, or in-repo adoption); -# ADOPT=0 is in-repo legacy reuse, which links `dsh` at the checkout as-is. -ADOPT=1 # REPO_COMMON is the shared git directory every worktree of the repository # points at; REPO_ROOT is the working tree that owns it (the master clone). REPO_COMMON='' @@ -265,46 +252,18 @@ if [ "$IN_REPO" = 1 ]; then case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac [ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common") fi - [ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it. Re-run with DSH_ADOPT=0 to link dsh at it as-is." + [ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it." REPO_ROOT=$(dirname -- "$REPO_COMMON") - # A repository with no commit cannot be branched, so adoption is impossible. - if ! git -C "$DSH_CHECKOUT" rev-parse --verify -q HEAD >/dev/null 2>&1; then - warn "checkout has no commits — cannot create a staging branch; linking dsh at it as-is." - ADOPT=0 - fi - - # Explicit DSH_ADOPT wins over the prompt in both directions. - if [ "${DSH_ADOPT:-}" = 0 ]; then - ADOPT=0 - elif [ "$ADOPT" = 1 ]; then - # Adoption branches from HEAD, so uncommitted work stays behind in the - # checkout and is NOT part of the install that ends up running. Warn even - # when DSH_ADOPT=1 skips the prompt: the surprise is the same either way. - if [ -n "$(git -C "$DSH_CHECKOUT" status --porcelain 2>/dev/null)" ]; then - warn "checkout has uncommitted changes; adoption branches from HEAD, so they stay here and will not be in the running install." - fi - fi - if [ "$ADOPT" = 1 ] && [ "${DSH_ADOPT:-}" != 1 ]; then - printf '%s\n' "${DIM}Adopting builds the managed layout under $DSH_SOURCE (staging worktree + current symlink) so this install stays upgradable.${RST}" - printf '%s\n' "${DIM}Declining links dsh straight at this checkout: not upgradable, and the PATH symlink breaks if the checkout moves.${RST}" - confirm "Adopt this checkout into the managed layout?" Y || ADOPT=0 - fi - - if [ "$ADOPT" = 0 ]; then - info "linking dsh at this checkout as-is (legacy in-repo reuse)" - DSH_STAGING=$DSH_CHECKOUT - else - # Reuse the container when the repository already lives inside it (the - # normal managed install re-running its own script); otherwise treat that - # clone as its own master and keep worktrees in the default container. - _src_resolved=$(resolve_dir "$DSH_SOURCE") - case "$REPO_ROOT/" in - "$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;; - *) info "adopting clone $REPO_ROOT as its own master" ;; - esac - DSH_MASTER=$REPO_ROOT - fi + # Reuse the container when the repository already lives inside it (the normal + # managed install re-running its own script); otherwise treat that clone as + # its own master and keep worktrees in the default container. + _src_resolved=$(resolve_dir "$DSH_SOURCE") + case "$REPO_ROOT/" in + "$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;; + *) info "adopting clone $REPO_ROOT as its own master" ;; + esac + DSH_MASTER=$REPO_ROOT else step "Fetching source into $DSH_MASTER" if [ -d "$DSH_MASTER/.git" ]; then @@ -324,39 +283,37 @@ else REPO_ROOT=$(resolve_dir "$DSH_MASTER") fi -if [ "$ADOPT" = 1 ]; then - step "Adding staging worktree at $DSH_STAGING" - [ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." - mkdir -p "$DSH_SOURCE" - # The staging worktree owns the branch dsh runs from; the repository stays as - # the fetch/upgrade base and is never a launcher target. A clone install - # branches from the ref it just fetched; adoption branches from the checkout's - # HEAD so the contributor's committed work is what runs. - if [ "$IN_REPO" = 1 ]; then - git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD - else - git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ - || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD - fi - # Exclude the per-worktree merge lock in the shared git dir's info/exclude, - # which every linked worktree inherits. - _exclude="$REPO_COMMON/info/exclude" - if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then - printf '.agents/merge.lock\n' >>"$_exclude" - fi - mkdir -p "$DSH_STAGING/.agents" - : >"$DSH_STAGING/.agents/merge.lock" - # A staging worktree holds an absolute gitdir pointer into the repository, so - # a container whose repository lives OUTSIDE it is not self-contained: deleting - # that repository breaks every worktree here. Record it only in that case, so - # the file's presence itself means "this container depends on an outside path". - _src_resolved=$(resolve_dir "$DSH_SOURCE") - case "$REPO_ROOT/" in - "$_src_resolved"/*) ;; - *) printf '%s\n' "$REPO_ROOT" >"$DSH_SOURCE/master.path" - info "recorded external repository in $DSH_SOURCE/master.path" ;; - esac +step "Adding staging worktree at $DSH_STAGING" +[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." +mkdir -p "$DSH_SOURCE" +# The staging worktree owns the branch dsh runs from; the repository stays as +# the fetch/upgrade base and is never a launcher target. A clone install +# branches from the ref it just fetched; adoption branches from the checkout's +# HEAD so the contributor's committed work is what runs. +if [ "$IN_REPO" = 1 ]; then + git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD +else + git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ + || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD fi +# Exclude the per-worktree merge lock in the shared git dir's info/exclude, +# which every linked worktree inherits. +_exclude="$REPO_COMMON/info/exclude" +if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then + printf '.agents/merge.lock\n' >>"$_exclude" +fi +mkdir -p "$DSH_STAGING/.agents" +: >"$DSH_STAGING/.agents/merge.lock" +# A staging worktree holds an absolute gitdir pointer into the repository, so +# a container whose repository lives OUTSIDE it is not self-contained: deleting +# that repository breaks every worktree here. Record it only in that case, so +# the file's presence itself means "this container depends on an outside path". +_src_resolved=$(resolve_dir "$DSH_SOURCE") +case "$REPO_ROOT/" in + "$_src_resolved"/*) ;; + *) printf '%s\n' "$REPO_ROOT" >"$DSH_SOURCE/master.path" + info "recorded external repository in $DSH_SOURCE/master.path" ;; +esac # --- 3. install dependencies (no build; the launcher runs from source) -------- step "Installing dependencies with pnpm (this can take a while)" @@ -365,37 +322,29 @@ step "Installing dependencies with pnpm (this can take a while)" [ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" # --- 4. put `dsh` on PATH ------------------------------------------------------ -# Managed installs go through a stable `current` symlink so an upgrade repoints +# Every install goes through a stable `current` symlink so an upgrade repoints # one symlink (current -> new worktree) and the PATH launcher never moves: -# PATH/dsh -> current/bin/dsh -> /bin/dsh. Declined adoption links PATH -# straight at the checkout, since that checkout is not a managed worktree. +# PATH/dsh -> current/bin/dsh -> /bin/dsh. step "Linking dsh into $DSH_BIN_DIR" mkdir -p "$DSH_BIN_DIR" -if [ "$ADOPT" = 0 ]; then - DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh - ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" - info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" - warn "this install is not upgradable (no current symlink) and the PATH link breaks if $DSH_STAGING moves." -else - # Point `current` at this staging worktree with `ln -sfn`: -f replaces an - # existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing - # an existing symlink-to-directory and dropping the new link *inside* the old - # worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir - # symlink the same way. The swap is one unlink+symlink pair on a local fs; the - # installer holds no other process racing this path. - # The launcher must resolve to a staging worktree, never to the repository - # itself: an upgrade repoints `current`, so aliasing it onto the master clone - # would make every upgrade rewrite the fetch/upgrade base. Compare physical - # paths — a symlinked or unresolved path would slip past a string compare. - _staging_resolved=$(resolve_dir "$DSH_STAGING") - [ -n "$REPO_ROOT" ] && [ "$_staging_resolved" = "$REPO_ROOT" ] \ - && die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree." - ln -sfn "$DSH_STAGING" "$DSH_CURRENT" - info "pointed $DSH_CURRENT -> $DSH_STAGING" - DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh - ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" - info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" -fi +# The launcher must resolve to a staging worktree, never to the repository +# itself: an upgrade repoints `current`, so aliasing it onto the master clone +# would make every upgrade rewrite the fetch/upgrade base. Compare physical +# paths — a symlinked or unresolved path would slip past a string compare. +_staging_resolved=$(resolve_dir "$DSH_STAGING") +[ "$_staging_resolved" = "$REPO_ROOT" ] \ + && die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree." +# Point `current` at this staging worktree with `ln -sfn`: -f replaces an +# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing +# an existing symlink-to-directory and dropping the new link *inside* the old +# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir +# symlink the same way. The swap is one unlink+symlink pair on a local fs; the +# installer holds no other process racing this path. +ln -sfn "$DSH_STAGING" "$DSH_CURRENT" +info "pointed $DSH_CURRENT -> $DSH_STAGING" +DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh +ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" +info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" case ":$PATH:" in *":$DSH_BIN_DIR:"*) ON_PATH=1 ;; From f3a1ff41b7e168df0cae4c926f14b8a388d623ec Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 21:17:28 +0800 Subject: [PATCH 03/11] cleanup(install): drop the master.path record Nothing read it. The launcher, dsh-upgrade, and the installer's own re-run all ignored the file, and the diagnostic it was meant to feed was never built, so it was write-only state. Git already owns the fact it recorded: a staging worktree's .git file names the repository path, and `git worktree list` in that clone enumerates every worktree depending on it. An installer-written copy only adds state that can go stale while nothing validates it. The containment caveat it documented is real and stays in the script header and the Agent Note, now pointing at git's own records. --- ...31-installer-adopts-existing-checkout.i18n.yaml | 4 ++-- ...026-07-31-installer-adopts-existing-checkout.md | 8 ++++---- ...-07-31-installer-adopts-existing-checkout.zh.md | 8 ++++---- scripts/install.sh | 14 ++------------ 4 files changed, 12 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index c4689a5f4c..fa13789c9a 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 75f71d1dc7f7d84674c7f11ec1affe616acbd0b2 -2026-07-31-installer-adopts-existing-checkout.zh.md: 381a3c0f67aa20198caa25b558f6d6d1b5591413 +2026-07-31-installer-adopts-existing-checkout.md: 5eede5d476d21c9f2bf0b63365eab9ac705bad60 +2026-07-31-installer-adopts-existing-checkout.zh.md: a137c585de7da5b1ccc1167c2c8e9d1ca939298b diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index 75f71d1dc7..5eede5d476 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -16,11 +16,11 @@ In-repo mode still never clones and never modifies the working tree, but it now 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`. -`$DSH_SOURCE/master.path` records the resolved repository, and only when that repository lives outside the container. A container holding its own master is self-contained and gets no file, so the file's presence is itself the signal that this container depends on an outside path: each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them. +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. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and wrote a spurious `master.path`, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and 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+. +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. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and so compared unequal against every resolved path, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and 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. @@ -42,10 +42,10 @@ One layout now serves every install, so an adopted clone is upgradable by `dsh-u 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. -The cost is that a container adopting an outside clone is 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 `master.path` is the mitigation, not a repair. +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` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. -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 both the built layout and the absence of `master.path`, which is the regression that caught the unresolved-`REPO_ROOT` defect. 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. +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, which is the regression that caught the unresolved-`REPO_ROOT` defect. 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. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 381a3c0f67..a137c585de 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -16,11 +16,11 @@ Status: implemented 容器拥有 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`发现的。 -`$DSH_SOURCE/master.path`记录解析出的仓库,且仅在该仓库位于容器之外时才记录。拥有自身 master 的容器是自包含的,不会生成该文件;因此该文件的存在本身就是一个信号,表明此容器依赖于外部路径:每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们。 +安装器不会记录该仓库位于何处。仓库位于容器之外时,容器就不是自包含的——每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们——但这一事实本就由 git 自己掌握:worktree 的`.git`文件写明了该路径,而在该克隆中执行`git worktree list`会列出依赖于它的每一个 worktree。 接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 -所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,导致写出多余的`master.path`;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`resolve_dir`会在路径不存在时原样回显该路径,而需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 +所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,从而与所有已解析路径比较时均不相等;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`resolve_dir`会在路径不存在时原样回显该路径,而需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级契约。 @@ -42,10 +42,10 @@ Status: implemented 代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`。 -代价是:接管外部克隆的容器不再自包含——删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——`master.path`是缓解措施,不是修复。 +此外,接管外部克隆的容器不再自包含:删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——诊断依据则是 git 自身的 worktree 记录。 ## Testing `scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 -验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装同时断言所构建的布局和`master.path`的缺失——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 +验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/scripts/install.sh b/scripts/install.sh index b26beccf2c..7393639a85 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -32,8 +32,8 @@ # # Adopting an arbitrary clone leaves the container not self-contained: its # staging worktrees hold an absolute gitdir pointer into that clone, so deleting -# it breaks them. $DSH_SOURCE/master.path records the resolved clone so the -# breakage is diagnosable. +# it breaks them. `git worktree list` in that clone is the record of which +# worktrees depend on it. # # When run through `curl | sh` the script text arrives on stdin, so every # prompt and the final launch read the controlling terminal (/dev/tty) directly; @@ -304,16 +304,6 @@ if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/nul fi mkdir -p "$DSH_STAGING/.agents" : >"$DSH_STAGING/.agents/merge.lock" -# A staging worktree holds an absolute gitdir pointer into the repository, so -# a container whose repository lives OUTSIDE it is not self-contained: deleting -# that repository breaks every worktree here. Record it only in that case, so -# the file's presence itself means "this container depends on an outside path". -_src_resolved=$(resolve_dir "$DSH_SOURCE") -case "$REPO_ROOT/" in - "$_src_resolved"/*) ;; - *) printf '%s\n' "$REPO_ROOT" >"$DSH_SOURCE/master.path" - info "recorded external repository in $DSH_SOURCE/master.path" ;; -esac # --- 3. install dependencies (no build; the launcher runs from source) -------- step "Installing dependencies with pnpm (this can take a while)" From adb88ad36d254ae024b4f4c28e74d13ad84b7bf5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:14:32 +0800 Subject: [PATCH 04/11] docs(skills): the master clone may live outside the container Adoption installs the master wherever the adopted clone already is, so dsh-upgrade and dsh-customize can no longer state /master as fact. Both skills already derive the master from the launcher, so the procedures hold; only the layout description was wrong. dsh-upgrade now names `git rev-parse --git-common-dir` as the way to resolve it. The legacy-migration clauses stay: installs made before this change can still link PATH straight at a worktree. --- skills/dsh-customize/SKILL.md | 2 +- skills/dsh-upgrade/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index 74da68b578..c94723acd1 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,7 +12,7 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps every checkout under one container `${DSH_SOURCE}` (default `~/.dsh/source`): the master clone at `${DSH_SOURCE}/master` and each staging checkout as a git worktree `${DSH_SOURCE}/staging-`. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The master clone is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone as the master wherever it lives, so derive it from the checkout rather than assuming it sits in the container. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. 4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the master clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the master clone, or a non-staging branch. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index 7570672cc8..307bfa3f55 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -9,7 +9,7 @@ Prepare and validate the upgrade in a fresh staging worktree of the master clone ## Layout -A source-installed DSH keeps every checkout under one container directory `` (default `~/.dsh/source`): the master clone at `/master` (remote tracking `master`, the fetch/upgrade base, never a launcher target) and each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the master clone's single `.git` object store; the master clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own master, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. +A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The master clone — remote tracking `master`, the fetch/upgrade base, never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone as the master wherever it already lives, so resolve it with `git rev-parse --git-common-dir` from the staging worktree instead of assuming a path. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the master clone's single `.git` object store; the master clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own master, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. ## Names From 2c29fedaf96ae01d0d0d57e6aa8605c43f6e7ea1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:21:55 +0800 Subject: [PATCH 05/11] docs(skills): call it the main clone, not the master clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Master clone" named the repository after a branch it need not be on. An adopted clone keeps whatever branch it had — verified: adopting a clone checked out on a feature branch leaves it there — so the name was wrong for every install that did not come from curl. Renamed to "main clone" in dsh-upgrade and dsh-customize, describing its actual role: the one real clone whose object store every worktree shares. dsh-upgrade also now says not to assume the main clone sits on `master` or that its `origin` is authoritative upstream, since an adopted clone may point at a fork. The fetch itself was already correct: step 1 resolves authoritative upstream separately, and step 4 fetches upstream `master` from it rather than from the clone's own branch. --- skills/dsh-customize/SKILL.md | 4 ++-- skills/dsh-upgrade/SKILL.md | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index c94723acd1..bca2c48b18 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,9 +12,9 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The master clone is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone as the master wherever it lives, so derive it from the checkout rather than assuming it sits in the container. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The main clone — the one real clone whose object store every worktree shares — is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone wherever it lives, so derive it from the checkout rather than assuming it sits in the container or on any particular branch. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. -4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the master clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the master clone, or a non-staging branch. +4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. ## Customize diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index 307bfa3f55..11f6613b4b 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -5,11 +5,11 @@ description: Upgrades a source-installed, personally customized DSH checkout to # DSH Upgrade -Prepare and validate the upgrade in a fresh staging worktree of the master clone, leave the worktree the installed launcher currently uses unchanged, then atomically repoint the stable `current` symlink once. Read and follow [`dsh-customize`](../dsh-customize/SKILL.md) before starting; it owns checkout discovery and lock handling. +Prepare and validate the upgrade in a fresh staging worktree of the main clone, leave the worktree the installed launcher currently uses unchanged, then atomically repoint the stable `current` symlink once. Read and follow [`dsh-customize`](../dsh-customize/SKILL.md) before starting; it owns checkout discovery and lock handling. ## Layout -A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The master clone — remote tracking `master`, the fetch/upgrade base, never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone as the master wherever it already lives, so resolve it with `git rev-parse --git-common-dir` from the staging worktree instead of assuming a path. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the master clone's single `.git` object store; the master clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own master, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. +A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The main clone — the one real clone holding the object store every worktree shares, and never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone wherever it already lives, so resolve it with `git rev-parse --git-common-dir` from the staging worktree instead of assuming a path. Do not assume the main clone sits on `master` or that its `origin` is authoritative upstream — an adopted clone keeps whatever branch and remotes it had, and may point at a fork. The upgrade fetches upstream separately, per step 1. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the main clone's single `.git` object store; the main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own main clone, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. ## Names @@ -22,24 +22,24 @@ One upgrade attempt uses one UTC basic timestamp `YYYYMMDDTHHMMSSZ` for all name - recovery ref: `refs/dsh-upgrade/recovery-`; - recorded `current` target before cutover: the old staging worktree path, kept for symlink rollback. -The worktree name is always `staging-` under ``, never derived from the current staging directory name, so successive upgrades stay in one place and do not accumulate timestamps. The preparation branch and private refs are local-only and must never be pushed. Before starting, reject a current staging branch named exactly `dsh-staging`, because Git cannot also create `dsh-staging/`; require the user to choose a non-conflicting staging namespace rather than silently renaming it. If the new staging worktree path exists, resume only when it is a clean worktree of this master clone whose recorded old tip, upstream ref, recovery ref, and named branches exactly match this attempt; otherwise stop. Never add an ad hoc suffix or delete an unknown directory. +The worktree name is always `staging-` under ``, never derived from the current staging directory name, so successive upgrades stay in one place and do not accumulate timestamps. The preparation branch and private refs are local-only and must never be pushed. Before starting, reject a current staging branch named exactly `dsh-staging`, because Git cannot also create `dsh-staging/`; require the user to choose a non-conflicting staging namespace rather than silently renaming it. If the new staging worktree path exists, resume only when it is a clean worktree of this main clone whose recorded old tip, upstream ref, recovery ref, and named branches exactly match this attempt; otherwise stop. Never add an ad hoc suffix or delete an unknown directory. ## Upgrade -1. Resolve the installed launcher, its staging worktree and branch, the master clone, the current DSH process source, and authoritative upstream. Record exact tips, paths, clean status, remotes, dependencies, worktrees, and in-progress Git operations. Require the installed staging worktree to be clean and its `.agents/merge.lock` to exist and be Git-excluded. Never stash automatically. -2. Treat the staging worktree behind the installed launcher as immutable for the whole attempt: do not touch its branch, HEAD, index, tracked or untracked files, dependencies, worktree registration, or lock file. Fetching into the shared master clone and creating new branches, worktrees, and private refs there are allowed because they are append-only and never alter the old worktree's checkout; opening and holding the existing lock is the only operation on the old worktree. +1. Resolve the installed launcher, its staging worktree and branch, the main clone, the current DSH process source, and authoritative upstream. Record exact tips, paths, clean status, remotes, dependencies, worktrees, and in-progress Git operations. Require the installed staging worktree to be clean and its `.agents/merge.lock` to exist and be Git-excluded. Never stash automatically. +2. Treat the staging worktree behind the installed launcher as immutable for the whole attempt: do not touch its branch, HEAD, index, tracked or untracked files, dependencies, worktree registration, or lock file. Fetching into the shared main clone and creating new branches, worktrees, and private refs there are allowed because they are append-only and never alter the old worktree's checkout; opening and holding the existing lock is the only operation on the old worktree. 3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. -4. In the master clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the master clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. +4. In the main clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the main clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. 5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. 6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds a current, independently useful contract absent upstream. Abort without changing the installed launcher when resolution is uncertain. 7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. -8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared master exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. -9. Recheck the old worktree, existing lock, launcher, `current`, master clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the master clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. +8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared main-clone exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. +9. Recheck the old worktree, existing lock, launcher, `current`, main clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the main clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. 10. On failure before the `current` cutover, leave `current`, the launcher, and the old worktree unchanged and remove only verified attempt artifacts created by this run (including the new worktree registration if empty). On failure during or after cutover, inspect `current`'s observed target before acting; if cutover did not verify, atomically repoint `current` back to its recorded pre-cutover target with `ln -sfn` and verify that `dsh` starts from the unchanged old staging worktree. This rollback is the sole exception allowing `current` to return to the old staging worktree. Never retry a side-effecting operation blindly. 11. Release the old worktree's lock and tell the user to restart DSH through the installed launcher. The current process may continue from the old worktree, but no operation may mutate or remove it until the restarted process proves that it runs from `dsh-staging/` and the user confirms stability. Avoid customization integration during this confirmation window; if rollback is required after new work lands, reconcile that work explicitly rather than silently stranding it. -12. After confirmation, remove the preparation branch if no process uses it. Keep the old staging worktree and branch, the recovery ref, and the recorded pre-cutover `current` target as rollback until the user explicitly approves their removal; leave the actual `git worktree remove` and directory deletion to the user. Report old, upstream, prepared, and new staging commits; both staging worktree paths and branches; the master clone path; process-source evidence; the `current` pre-cutover target and cutover; commands and checks; final status; recovery ref; and retained rollback artifacts. +12. After confirmation, remove the preparation branch if no process uses it. Keep the old staging worktree and branch, the recovery ref, and the recorded pre-cutover `current` target as rollback until the user explicitly approves their removal; leave the actual `git worktree remove` and directory deletion to the user. Report old, upstream, prepared, and new staging commits; both staging worktree paths and branches; the main clone path; process-source evidence; the `current` pre-cutover target and cutover; commands and checks; final status; recovery ref; and retained rollback artifacts. -The installed launcher always resolves through `current` to a staging worktree, never the master clone. Upgrade preparation adds a new worktree that shares the master object store while leaving the old worktree's checkout untouched; cutover is one atomic `current` repoint to the separately validated timestamped staging worktree, and the PATH launcher never moves. +The installed launcher always resolves through `current` to a staging worktree, never the main clone. Upgrade preparation adds a new worktree that shares the main clone's object store while leaving the old worktree's checkout untouched; cutover is one atomic `current` repoint to the separately validated timestamped staging worktree, and the PATH launcher never moves. ## Recommend upstream candidates From f3ff2e6ab49329878d600dfe4d5215430d8f0b93 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:26:11 +0800 Subject: [PATCH 06/11] docs(skills): say how to resolve the main clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both skills said to derive the main clone from the checkout without saying how, and dsh-upgrade names dsh-customize as the owner of checkout discovery — so the technique belonged there and was missing. dsh-customize now gives it: `git rev-parse --git-common-dir` from the checkout yields the shared git directory, whose parent is the main clone. It also names the two ways to get this wrong — the answer is relative for a plain clone, and paths must be compared physically, since macOS reaches /var through a symlink to /private/var. dsh-upgrade links to that procedure rather than restating it. Verified against both shapes: an adopted clone outside the container, and a curl-shaped install whose clone is at /master. --- skills/dsh-customize/SKILL.md | 2 +- skills/dsh-upgrade/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index bca2c48b18..9c9c714768 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,7 +12,7 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The main clone — the one real clone whose object store every worktree shares — is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone wherever it lives, so derive it from the checkout rather than assuming it sits in the container or on any particular branch. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The main clone — the one real clone whose object store every worktree shares — is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone wherever it lives, so never assume it sits in the container or on any particular branch. Resolve it from the checkout: `git -C rev-parse --git-common-dir` gives the shared git directory (a linked worktree reports the real clone's, not its own), and its parent is the main clone. That answer is relative for a plain clone, so anchor it against the checkout before use, and resolve it physically — comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. 4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index 11f6613b4b..cb56edffbd 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -9,7 +9,7 @@ Prepare and validate the upgrade in a fresh staging worktree of the main clone, ## Layout -A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The main clone — the one real clone holding the object store every worktree shares, and never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone wherever it already lives, so resolve it with `git rev-parse --git-common-dir` from the staging worktree instead of assuming a path. Do not assume the main clone sits on `master` or that its `origin` is authoritative upstream — an adopted clone keeps whatever branch and remotes it had, and may point at a fork. The upgrade fetches upstream separately, per step 1. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the main clone's single `.git` object store; the main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own main clone, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. +A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The main clone — the one real clone holding the object store every worktree shares, and never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone wherever it already lives, so resolve it from the staging worktree by the procedure in [`dsh-customize`](../dsh-customize/SKILL.md) instead of assuming a path. Do not assume the main clone sits on `master` or that its `origin` is authoritative upstream — an adopted clone keeps whatever branch and remotes it had, and may point at a fork. The upgrade fetches upstream separately, per step 1. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the main clone's single `.git` object store; the main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own main clone, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. ## Names From 8d624678237271e9a62caed00be896fd786fc3f5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:29:53 +0800 Subject: [PATCH 07/11] docs(skills): one canonical resolution, no DSH_SOURCE DSH_SOURCE is an install-time shell variable the installer never exports, so a skill reading ${DSH_SOURCE} at runtime reads nothing. Verified unset in a running dsh process. Git resolves the main clone identically for every install, so the curl-vs- adopted distinction was never a branch point in these workflows. Verified one launcher-then-Git recipe against three shapes: a curl install cloning into the container, an adopted clone nested far outside any container, and a custom DSH_SOURCE container. dsh-customize now states that single procedure and warns off the installer variables. dsh-upgrade's Layout describes what the resolution finds rather than a path convention, and no longer teaches install shapes as cases. --- skills/dsh-customize/SKILL.md | 4 +++- skills/dsh-upgrade/SKILL.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index 9c9c714768..fc9e507ba6 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,7 +12,9 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps staging checkouts under one container `${DSH_SOURCE}` (default `~/.dsh/source`), each a git worktree `${DSH_SOURCE}/staging-`. The main clone — the one real clone whose object store every worktree shares — is at `${DSH_SOURCE}/master` for a `curl` install, but installing from an existing clone adopts that clone wherever it lives, so never assume it sits in the container or on any particular branch. Resolve it from the checkout: `git -C rev-parse --git-common-dir` gives the shared git directory (a linked worktree reports the real clone's, not its own), and its parent is the main clone. That answer is relative for a plain clone, so anchor it against the checkout before use, and resolve it physically — comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `git -C
worktree list` then enumerates every checkout sharing it. + + This one procedure covers every install. [`scripts/install.sh`](../../scripts/install.sh) puts staging worktrees and `current` under a container directory (default `~/.dsh/source`), and a `curl` install also clones into that container while installing from an existing clone adopts that clone where it already lives — but nothing in this workflow depends on which happened, on the container's path, or on the main clone's branch. `DSH_SOURCE` and the installer's other variables exist only while the installer runs; they are never exported, so never read them here. An older install may link PATH straight at a worktree with no `current`, which the same launcher-then-Git procedure resolves unchanged. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. 4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index cb56edffbd..dca546258a 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -9,7 +9,9 @@ Prepare and validate the upgrade in a fresh staging worktree of the main clone, ## Layout -A source-installed DSH keeps its staging checkouts and `current` under one container directory `` (default `~/.dsh/source`): each staging checkout is a git worktree `/staging-` on branch `dsh-staging/`. The main clone — the one real clone holding the object store every worktree shares, and never a launcher target — is at `/master` for a `curl` install, but the container owns worktrees rather than the repository: installing from an existing clone adopts that clone wherever it already lives, so resolve it from the staging worktree by the procedure in [`dsh-customize`](../dsh-customize/SKILL.md) instead of assuming a path. Do not assume the main clone sits on `master` or that its `origin` is authoritative upstream — an adopted clone keeps whatever branch and remotes it had, and may point at a fork. The upgrade fetches upstream separately, per step 1. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the main clone's single `.git` object store; the main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own main clone, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. +Resolve the layout, never assume it. [`dsh-customize`](../dsh-customize/SKILL.md) owns the procedure: follow the PATH launcher to the staging worktree, then derive the main clone from that checkout with Git. It resolves every install the same way, so this workflow needs no special case for how DSH was installed and never reads the installer's variables, which exist only while the installer runs. + +The resolved layout is one container directory `` holding each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`, plus the stable symlink `/current` pointing at the active one; the PATH launcher links to `/current/bin/dsh`, so it resolves PATH -> `current` -> staging worktree. The main clone is the one real clone whose object store every worktree shares, and is never a launcher target. It may live inside `` or anywhere else on disk, on any branch, with remotes that may point at a fork — so treat it strictly as the object store and worktree host, and take authoritative upstream from step 1 instead. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. The main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree with no `current`; the same resolution finds it, and cutover then creates `current` and repoints PATH to `current/bin/dsh` as a one-time migration. ## Names From 6e913fe8bb682d582d064a21c9b5220920df3aa4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:35:27 +0800 Subject: [PATCH 08/11] docs(skills): drop installer details from the git workflows These skills resolve the layout from the PATH launcher and Git, so how the checkout was installed never enters the procedure. Describing install shapes, the installer script, and its variables added detail a reader must hold and would go stale whenever the installer changes. Both skills now describe the observable state they resolve. The cases that mattered survive as properties of that state: the main clone may sit anywhere on any branch, and a launcher may link straight at a worktree with no `current`. --- skills/dsh-customize/SKILL.md | 2 +- skills/dsh-upgrade/SKILL.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index fc9e507ba6..865e21dba5 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -14,7 +14,7 @@ Do not assume a path or branch name. DSH is usually installed from source with a 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. 2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `git -C
worktree list` then enumerates every checkout sharing it. - This one procedure covers every install. [`scripts/install.sh`](../../scripts/install.sh) puts staging worktrees and `current` under a container directory (default `~/.dsh/source`), and a `curl` install also clones into that container while installing from an existing clone adopts that clone where it already lives — but nothing in this workflow depends on which happened, on the container's path, or on the main clone's branch. `DSH_SOURCE` and the installer's other variables exist only while the installer runs; they are never exported, so never read them here. An older install may link PATH straight at a worktree with no `current`, which the same launcher-then-Git procedure resolves unchanged. + This resolves every checkout, so depend on nothing else: not an environment variable, not a container path, not the main clone's location or branch. A checkout whose launcher links straight at it, with no `current` in the chain, resolves the same way. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. 4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index dca546258a..30c5cc7bdc 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -9,9 +9,9 @@ Prepare and validate the upgrade in a fresh staging worktree of the main clone, ## Layout -Resolve the layout, never assume it. [`dsh-customize`](../dsh-customize/SKILL.md) owns the procedure: follow the PATH launcher to the staging worktree, then derive the main clone from that checkout with Git. It resolves every install the same way, so this workflow needs no special case for how DSH was installed and never reads the installer's variables, which exist only while the installer runs. +Resolve the layout, never assume it. [`dsh-customize`](../dsh-customize/SKILL.md) owns the procedure: follow the PATH launcher to the staging worktree, then derive the main clone from that checkout with Git. One resolution covers every checkout, so this workflow needs no special case and depends on no environment variable. -The resolved layout is one container directory `` holding each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`, plus the stable symlink `/current` pointing at the active one; the PATH launcher links to `/current/bin/dsh`, so it resolves PATH -> `current` -> staging worktree. The main clone is the one real clone whose object store every worktree shares, and is never a launcher target. It may live inside `` or anywhere else on disk, on any branch, with remotes that may point at a fork — so treat it strictly as the object store and worktree host, and take authoritative upstream from step 1 instead. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. The main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree with no `current`; the same resolution finds it, and cutover then creates `current` and repoints PATH to `current/bin/dsh` as a one-time migration. +The resolved layout is one container directory `` holding each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`, plus the stable symlink `/current` pointing at the active one; the PATH launcher links to `/current/bin/dsh`, so it resolves PATH -> `current` -> staging worktree. The main clone is the one real clone whose object store every worktree shares, and is never a launcher target. It may live inside `` or anywhere else on disk, on any branch, with remotes that may point at a fork — so treat it strictly as the object store and worktree host, and take authoritative upstream from step 1 instead. Cutover repoints `current` alone, so the PATH launcher itself never moves. The main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. When the launcher links straight at a worktree with no `current` in the chain, the same resolution finds it, and cutover creates `current` and repoints PATH to `current/bin/dsh` as a one-time migration. ## Names From db432c1e740fd4ec75078c43e398e286a6d471b5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 23:40:40 +0800 Subject: [PATCH 09/11] fix(install): correct a false claim about shell assignment semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged the resolve_dir comment: it claimed `x=$(cmd) || fallback` never fires "because the assignment succeeds even when the substitution fails." That is wrong — command substitution propagates exit status and the fallback does fire, confirmed in sh, bash, dash, and zsh. Reproducing the original code shows the fallback also worked, so the second "recurrence" the Agent Note described never existed. Both real defects were the same one: comparing a resolved path against an unresolved one. The note now says that instead of inventing a mechanism. resolve_dir keeps its `|| printf` because it makes every caller a plain assignment, so no site can compare against an empty path by forgetting its own fallback — the reason is now stated accurately. Also from review: REPO_COMMON is now resolved on both branches, matching REPO_ROOT, and _repo_root notes why it is already physical without its own resolve_dir call. --- ...1-installer-adopts-existing-checkout.i18n.yaml | 4 ++-- ...26-07-31-installer-adopts-existing-checkout.md | 2 +- ...07-31-installer-adopts-existing-checkout.zh.md | 2 +- scripts/install.sh | 15 +++++++++------ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index fa13789c9a..b735ee0a05 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 5eede5d476d21c9f2bf0b63365eab9ac705bad60 -2026-07-31-installer-adopts-existing-checkout.zh.md: a137c585de7da5b1ccc1167c2c8e9d1ca939298b +2026-07-31-installer-adopts-existing-checkout.md: f2f4a2bf87696bc2254a352dd7568ea73f8f900b +2026-07-31-installer-adopts-existing-checkout.zh.md: b7a545e6eb43bd8748185b26b6a7ee965353b79b diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index 5eede5d476..f2f4a2bf87 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -20,7 +20,7 @@ The installer records nothing about where that repository lives. A container who 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. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review — once where a curl install's `REPO_ROOT` stayed unresolved and so compared unequal against every resolved path, and once where `x=$(resolve_dir …) || x=$fallback` left an empty path because the assignment succeeds even when the substitution fails. `resolve_dir` therefore echoes a missing path back itself, and 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+. +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. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review, both times as one side of a comparison left unresolved: a curl install's `REPO_ROOT`, and 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. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index a137c585de..b7a545e6eb 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -20,7 +20,7 @@ Status: implemented 接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 -所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次——一次是 curl 安装的`REPO_ROOT`未经解析,从而与所有已解析路径比较时均不相等;另一次是`x=$(resolve_dir …) || x=$fallback`留下了空路径,因为即使命令替换失败,赋值本身仍然成功。因此`resolve_dir`会在路径不存在时原样回显该路径,而需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 +所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次,两次都是比较的一侧未经解析:一次是 curl 安装的`REPO_ROOT`,一次是与之比较的容器路径。因此`resolve_dir`在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级契约。 diff --git a/scripts/install.sh b/scripts/install.sh index 7393639a85..9a891f05d8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -78,9 +78,9 @@ DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP # `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+. # # A not-yet-created directory (the container on a fresh install) has no physical -# path, so fall back to the literal argument here rather than at each call site: -# `x=$(cmd) || fallback` never fires, because the assignment succeeds even when -# the substitution fails, which would silently yield an empty path. +# path. Falling back here rather than at each call site keeps every caller a +# plain assignment, so no site can compare against an empty path by forgetting +# its own fallback. resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; } # --- in-repo detection --------------------------------------------------------- @@ -95,6 +95,8 @@ DSH_CHECKOUT='' if [ -f "$0" ]; then _self_dir=$(resolve_dir "$(dirname -- "$0")") if [ -n "$_self_dir" ]; then + # Physical without its own resolve_dir: dirname is textual, so trimming a + # resolved path leaves one. The comparison below depends on that. _repo_root=$(dirname -- "$_self_dir") if [ "$(basename -- "$_self_dir")" = scripts ] \ && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then @@ -277,9 +279,10 @@ else mkdir -p "$DSH_SOURCE" git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" fi - REPO_COMMON=$DSH_MASTER/.git - # Physical, to match the adoption branch: every REPO_ROOT comparison below - # runs against resolved paths. + # Physical on both branches: REPO_ROOT is compared against resolved paths + # below, and REPO_COMMON stays symmetric with it so neither can be read as + # carrying a different kind of path. + REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git") REPO_ROOT=$(resolve_dir "$DSH_MASTER") fi From c43535056126febbc8ee4a1044b0bb96ec58daf5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 23:47:58 +0800 Subject: [PATCH 10/11] docs(install): the path bug is symlinks, not /var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments and Agent Note blamed macOS resolving /var through private/var. That is only how the mismatch surfaced in temp fixtures, since mktemp hands back a /var path there. The real condition is broader: 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 alone triggers it — reproduced with no /var involved — which is common wherever homes live behind a symlink or on a network mount. Naming the cause correctly keeps a reader from dismissing resolve_dir as macOS-only defensiveness. --- ...-07-31-installer-adopts-existing-checkout.i18n.yaml | 4 ++-- .../2026-07-31-installer-adopts-existing-checkout.md | 2 +- ...2026-07-31-installer-adopts-existing-checkout.zh.md | 2 +- scripts/install.sh | 10 ++++++---- skills/dsh-customize/SKILL.md | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index b735ee0a05..1a179748f9 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: f2f4a2bf87696bc2254a352dd7568ea73f8f900b -2026-07-31-installer-adopts-existing-checkout.zh.md: b7a545e6eb43bd8748185b26b6a7ee965353b79b +2026-07-31-installer-adopts-existing-checkout.md: de3cd052f94a0d5256c7687e9a1a38ee69fd2caf +2026-07-31-installer-adopts-existing-checkout.zh.md: 2e8be804b4af6151e77e36f8b109616aab3a18e9 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index f2f4a2bf87..de3cd052f9 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -20,7 +20,7 @@ The installer records nothing about where that repository lives. A container who 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. macOS resolves `/var` through a symlink to `/private/var`, so comparing a git-reported path against an unresolved one misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review, both times as one side of a comparison left unresolved: a curl install's `REPO_ROOT`, and 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+. +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 recurred twice more during review, both times as one side of a comparison left unresolved: a curl install's `REPO_ROOT`, and 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. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index b7a545e6eb..2e8be804b4 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -20,7 +20,7 @@ Status: implemented 接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 -所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。macOS 会把`/var`经符号链接解析为`/private/var`,因此拿 git 报告的路径与未解析的路径相比较,会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次,两次都是比较的一侧未经解析:一次是 curl 安装的`REPO_ROOT`,一次是与之比较的容器路径。因此`resolve_dir`在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 +所有路径比较都通过`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 克隆"这一升级契约。 diff --git a/scripts/install.sh b/scripts/install.sh index 9a891f05d8..bdce88f72b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -71,10 +71,12 @@ DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP # --- path helpers --------------------------------------------------------------- -# Every path comparison below runs on physical paths. macOS resolves /var through -# a symlink to /private/var, so comparing a git-reported (already resolved) path -# against an unresolved one silently misclassifies an existing managed install as -# a foreign clone and builds a second container beside the real one. +# Every path comparison below runs on physical paths. 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 silently misclassifies an existing managed install as a foreign clone +# and builds a second container beside the real one. # `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+. # # A not-yet-created directory (the container on a fresh install) has no physical diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index 865e21dba5..0da62ee563 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,7 +12,7 @@ Make personal DSH changes in task worktrees and integrate them under the staging Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. 1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: comparing a resolved path against an unresolved one silently misidentifies the clone, since macOS reaches `/var` through a symlink to `/private/var`. `git -C
worktree list` then enumerates every checkout sharing it. +2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: Git reports resolved paths, so comparing one against an unresolved path silently misidentifies the clone whenever a symlink sits anywhere above the checkout, which a symlinked home directory alone is enough to cause. `git -C
worktree list` then enumerates every checkout sharing it. This resolves every checkout, so depend on nothing else: not an environment variable, not a container path, not the main clone's location or branch. A checkout whose launcher links straight at it, with no `current` in the chain, resolves the same way. 3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. From 1d86be1b74c8ac6452d39b184edb7c2d96a79655 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:35:48 +0800 Subject: [PATCH 11/11] docs(install): correct managed-layout comments --- scripts/install.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index bdce88f72b..485be277e0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -53,16 +53,16 @@ set -eu DSH_REF=${DSH_REF:-master} DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git} -# DSH_SOURCE is the container directory that holds the master clone and every -# staging worktree; DSH_MASTER is the one real clone inside it. Remember whether -# the caller pinned the source container before defaulting it, so in-repo -# detection only repoints an unset DSH_SOURCE. +# DSH_SOURCE is the staging-worktree container and the default home of `current`. +# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE, +# while adoption discovers an existing clone anywhere on disk. Remember whether +# DSH_SOURCE was explicit so a different path selects clone mode. if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source} DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master} -# The stable symlink the PATH launcher resolves through: PATH -> current/bin/dsh -# -> /bin/dsh. Fresh installs and upgrades repoint this one symlink; the -# PATH launcher itself is written once and never moves. In-repo reuse ignores it. +# The stable symlink the PATH launcher resolves through: PATH/dsh -> +# current/bin/dsh -> /bin/dsh. Installs and upgrades repoint `current`; +# the PATH target remains current/bin/dsh. DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current} DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin} # One UTC basic timestamp names this install's staging branch and worktree.