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..1a179748f9 --- /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: 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 new file mode 100644 index 0000000000..de3cd052f9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -0,0 +1,51 @@ +# 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 **adopts** the checkout into the managed layout unconditionally. There is no opt-out: one layout serves every install. + +The container owns staging worktrees and `current`; the repository is *discovered*, not owned. `git rev-parse --git-common-dir` resolves the shared git directory behind the checkout — for a linked worktree that is the real clone rather than the worktree itself — and its parent is the repository that serves as the upgrade base. A staging worktree branched from the checkout's `HEAD` is then created under `$DSH_SOURCE`, and `current` points at it. A clone anywhere on disk therefore converges on the same layout as a `curl` install, and the two paths share one worktree/exclude/lock/link sequence: they differ only in whether the repository was discovered by `git clone` or by `git rev-parse`. + +The installer records nothing about where that repository lives. A container whose repository sits outside it is not self-contained — each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them — but git already owns that fact: the worktree's `.git` file names the path, and `git worktree list` in the clone enumerates every worktree depending on it. + +Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout. This is not prompted or warned about: the installer builds the layout and gets out of the way. Setting `DSH_SOURCE` to a different directory remains the one documented way to opt back into cloning a separate tree. + +Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. Git always reports resolved paths, so comparing one against an unresolved path disagrees whenever a symlink sits anywhere above the checkout — a symlinked home directory is enough, and macOS reaches every `mktemp` path that way through `/var` -> `private/var`. The mismatch misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect 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. + +## 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 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 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. + +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 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 new file mode 100644 index 0000000000..2e8be804b4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -0,0 +1,51 @@ +# 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`发现的。 + +安装器不会记录该仓库位于何处。仓库位于容器之外时,容器就不是自包含的——每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们——但这一事实本就由 git 自己掌握:worktree 的`.git`文件写明了该路径,而在该克隆中执行`git worktree list`会列出依赖于它的每一个 worktree。 + +接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 + +所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。git 报告的始终是已解析的路径,因此只要检出之上任意一层存在符号链接,拿它与未解析的路径相比较就会不相等——家目录本身是符号链接即已足够,而 macOS 通过`/var` -> `private/var`使每个`mktemp`路径都如此。这种不匹配会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次,两次都是比较的一侧未经解析:一次是 curl 安装的`REPO_ROOT`,一次是与之比较的容器路径。因此`resolve_dir`在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 + +在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级契约。 + +## Alternatives considered + +**把`~/.dsh/source/master`做成指向该任意克隆的符号链接。** 已否决。Git 会解析该符号链接并记录*真实*路径:经由它创建的 worktree 会存储`gitdir: …/<克隆>/.git/worktrees/<名称>`,而`git worktree list`报告的是该克隆。因此这个符号链接纯属装饰——没有任何代码读取它——却又暗示容器拥有该仓库。它还会静默失效:移动克隆后,`master`看似仍在却已悬空,而每个 staging worktree 都会以`fatal: not a git repository`失败。最糟的是,它把两个名称别名到同一棵树上,于是"current 绝不能是 master 克隆"这项检查会在字符串比较下通过,实则为假。`~/.dsh/source/master`是位置而非名称,且只有位置具有权威性。 + +**把检出自身提升为`current`的目标。** 已否决:升级契约要求`current`必须是位于 staging 分支上的干净 staging worktree,绝不能是 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 + +**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。分叉的形态本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动需要推敲的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的形态而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍是安装另一棵树的退路。 + +**在工作树不干净时发出警告或提示。** 已否决:以`HEAD`为起点的`worktree add`本就无法带上未提交的内容,因此该行为是确定的,提示只会增加一个用户无法做出不同选择的决策点。改为在文档中说明该契约。 + +**把被接管克隆的 staging worktree 放在该克隆旁边**(`~/src/staging-*`),而非放进`~/.dsh/source`。已否决:`current`和 PATH 启动器都是每用户唯一的,因此把 worktree 散落到各个克隆的父目录中,会重新引入 source 容器本就为之而设、意在杜绝的同级克隆蔓延问题。 + +## Consequences + +现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的形态。检出内运行仍然绝不改动工作树。 + +代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`。 + +此外,接管外部克隆的容器不再自包含:删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——诊断依据则是 git 自身的 worktree 记录。 + +## Testing + +`scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 + +验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/apps/cli/tests/install-script.spec.ts b/apps/cli/tests/install-script.spec.ts index 8c25ff6891..7b616958a1 100644 --- a/apps/cli/tests/install-script.spec.ts +++ b/apps/cli/tests/install-script.spec.ts @@ -77,8 +77,9 @@ function executable(path: string, content: string): void { async function createFixture(): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-install-')) fixtures.push(root) - const scriptsDirectory = join(root, 'scripts') - const sourceBinDirectory = join(root, 'bin') + const checkoutDirectory = join(root, 'checkout') + const scriptsDirectory = join(checkoutDirectory, 'scripts') + const sourceBinDirectory = join(checkoutDirectory, 'bin') const fakeBinDirectory = join(root, 'fake-bin') const binDirectory = join(root, 'path-bin') for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) { @@ -93,6 +94,13 @@ async function createFixture(): Promise { if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG" `) + await execa('git', ['init', '-q'], { cwd: checkoutDirectory }) + await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory }) + await execa('git', [ + '-c', 'user.name=dsh-test', + '-c', 'user.email=dsh-test@example.invalid', + 'commit', '-qm', 'fixture', + ], { cwd: checkoutDirectory }) writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n') return { binDirectory, launchLog, pnpmLog, root, script } } diff --git a/scripts/install.sh b/scripts/install.sh index 0525da9e34..78299aab81 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -19,12 +19,22 @@ # 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: 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 +# 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; @@ -44,16 +54,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. @@ -61,26 +71,44 @@ 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. 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 +# 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 --------------------------------------------------------- # 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. 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 + # 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 - 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" ]; 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 @@ -149,7 +177,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" @@ -204,42 +232,84 @@ 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. +# +# 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." + REPO_ROOT=$(dirname -- "$REPO_COMMON") + + # 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 - 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 + # 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 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" +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" -fi # --- 3. install dependencies (no build; the launcher runs from source) -------- step "Installing dependencies with pnpm (this can take a while)" @@ -248,29 +318,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 ------------------------------------------------------ -# Clone 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. In-repo reuse 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 [ "$IN_REPO" = 1 ]; 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" -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. - 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 ;; diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md index 74da68b578..0da62ee563 100644 --- a/skills/dsh-customize/SKILL.md +++ b/skills/dsh-customize/SKILL.md @@ -12,9 +12,11 @@ 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 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. -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 7570672cc8..30c5cc7bdc 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -5,11 +5,13 @@ 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 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. +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, 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 @@ -22,24 +24,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