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)"