From 00c5f2abd1922a25d177941734c028790426ca6d Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 20:31:30 +0800 Subject: [PATCH] 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 ;;