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