diff --git a/docs/design/worktree.md b/docs/design/worktree.md index 8c9d4f0d56d..b6187da5477 100644 --- a/docs/design/worktree.md +++ b/docs/design/worktree.md @@ -18,8 +18,9 @@ qwen-code 目前仅有面向 Arena 多模型对比场景的内部 worktree 实 | Post-creation setup(hooks 配置) | ❌ | ✅ | Phase C | | StatusLine worktree 状态展示 | ❌ | ✅ | Phase C | | WorktreeExitDialog(退出提示) | ❌ | ✅ | Phase C | -| `--worktree` CLI 启动标志 | ❌ | ✅ | Phase D | -| 符号链接目录(node_modules 等) | ❌ | ✅ | Phase D | +| `--worktree` CLI 启动标志 | ✅(Phase D) | ✅ | — | +| 符号链接目录(node_modules 等) | ✅(Phase D) | ✅ | — | +| PR 引用(`--worktree=#123`) | ✅(Phase D) | ✅ | — | | sparse checkout | ❌ | ✅ | Future | | tmux 集成 | ❌ | ✅ | Future | | Arena 多模型 worktree 隔离 | ✅(qwen 独有) | ❌ | — | @@ -56,12 +57,13 @@ Arena 的 worktree 路径由 `agents.arena.worktreeBaseDir` 控制,默认 `~/. ### 扩展配置 -| 配置项 | 类型 | 用途 | 阶段 | -| ----------------------------- | ---------- | -------------------------------------------------------------- | ------- | -| `worktree.symlinkDirectories` | `string[]` | 符号链接指定目录(如 `node_modules`)到 worktree,避免磁盘浪费 | Phase D | -| `worktree.sparsePaths` | `string[]` | git sparse-checkout cone 模式,大型 monorepo 只写入指定路径 | Future | +| 配置项 | 类型 | 用途 | 阶段 | +| --------------------------------- | ---------- | ---------------------------------------------------------------- | ------- | +| `ui.hideBuiltinWorktreeIndicator` | `boolean` | 隐藏 Footer 中内置 `⎇ worktree-… (…)` 行,留给 custom statusline | Phase C | +| `worktree.symlinkDirectories` | `string[]` | 符号链接指定目录(如 `node_modules`)到 worktree,避免磁盘浪费 | Phase D | +| `worktree.sparsePaths` | `string[]` | git sparse-checkout cone 模式,大型 monorepo 只写入指定路径 | Future | -Phase A / B / C 不新增任何配置项。 +Phase A / B 不新增任何配置项。 ## 工具设计 @@ -233,32 +235,154 @@ _WorktreeExitDialog:_ --- -### Phase D:启动时配置(`--worktree` CLI 标志 + 目录符号链接) +### Phase D:启动时配置(`--worktree` CLI 标志 + 目录符号链接 + PR 引用) -**目标:** 支持在启动时直接进入 worktree,并通过目录符号链接减少大型项目的磁盘开销。 +**目标:** 支持在启动时直接进入 worktree、通过目录符号链接减少大型项目的磁盘开销,以及通过 PR 引用快速基于一个 pull request 创建 worktree。 -**要实现的功能:** +**范围:** 三个功能在一个阶段一起落地,因为它们都挂在同一个启动入口上,且 symlink / PR fetch 两者都需要在 worktree 创建之后立即执行 — 单独拆分会重复改 bootstrap 序列。 -_`--worktree [name]` CLI 启动标志:_ +#### D-1:`--worktree [name]` CLI 启动标志 -- `packages/cli/src/args.ts` 新增 `--worktree [name]` 参数 -- 启动流程在进入主循环前调用 `createUserWorktree()`,将 `targetDir` 设为 worktree 路径,并写入 SessionService 状态 -- 整个会话从启动即在 worktree 环境中运行,退出时触发 WorktreeExitDialog +**参数形态:** yargs 选项接受三种形式: -_`worktree.symlinkDirectories` 配置项:_ +| 形式 | 行为 | +| ------------------------- | ---------------------------------------------------- | +| `qwen --worktree` | bare flag,自动生成 slug(`{形容词}-{名词}-{6hex}`) | +| `qwen --worktree my-name` | 显式 slug,沿用 `EnterWorktreeTool` 的 slug 校验规则 | +| `qwen --worktree=my-name` | 等价于上一种 | -- settings schema 新增 `worktree.symlinkDirectories: string[]` -- `createUserWorktree()` 后遍历配置,调用 `fs.symlink()` 将主仓库目录链接进 worktree -- 跳过目标不存在的项;目标已存在时跳过(不覆盖) +不提供短别名 `-w`(qwen-code 短别名只保留给最高频参数,避免命名冲突)。 -**影响文件:** +**启动序列:** worktree 在以下位置创建: + +1. `parseArguments()` 解析 argv(已有) +2. resume picker(已有,line 588-629 of `gemini.tsx`) +3. `loadCliConfig()` 初始化 Config + auth(已有,line 643-653) +4. **新增:** 若 `argv.worktree !== undefined`,调用 `createUserWorktree()` + - 写入 sidecar(`writeWorktreeSession()`) + - 设置 `process.chdir(worktreePath)` 同时 `Config.setTargetDir(worktreePath)` + - 同一 worktree 的 re-attach 路径:跳过 `git worktree add` 并就地 chdir(Phase 6 修复)。跨 projectHash 的 `--resume` × `--worktree` 组合在 session lookup 阶段会失败,详见下文"与 `--resume` 的优先级"。 +5. 主循环(TUI / headless `-p` / ACP 三种入口都要走第 4 步) + +**与 Phase A 简化的差异:** Phase A 的 `EnterWorktreeTool` **不**修改 `Config.targetDir`,依赖模型从工具结果里读到绝对路径并继续使用。Phase D 的 CLI flag 在启动期就生效,没有运行中的模型上下文需要兼容,所以**直接切换 `targetDir` 和 `process.cwd()`** —— 这是更强的隔离保证。两条路径行为不同,需要在用户文档里说明。 + +**退出行为:** 复用现有 `WorktreeExitDialog`(Phase C 已实现)。Ctrl+C/D 两次触发 → 用户在 keep / remove / cancel 之间选择。不需要新代码路径。 + +**与 `--resume` 的优先级:** + +由于 session 存储以 `projectHash(process.cwd())` 为 key,而 `--worktree` 在 resume picker / `loadCliConfig` 之前就 chdir 到 worktree,所以"在 worktree X 启动的 session,从 worktree Y 内 resume"是**架构上不可达**的(两者的 projectHash 不同,session 文件落在不同目录)。下表反映 D-1 实现 + Phase 6 re-attach 修复后的实际行为: + +| `--resume` 状态 | `--worktree` 状态 | 结果 | +| ---------------------------- | -------------------------- | ------------------------------------------------------------------------------------------ | +| 无 | 无 | 普通会话,无 worktree | +| 无 | 有(新 slug) | 新建 worktree | +| 无 | 有(已存在的 slug) | **re-attach** 到已有 worktree(Phase 6 修复) | +| 有 | 无 | 恢复旧 worktree(Phase C 行为,sidecar 命中则注入 reminder) | +| 有(sid 出自同一 worktree) | 有(同一 slug,re-attach) | re-attach + session 命中:正常 resume | +| 有(sid 出自 main checkout) | 有(任意 slug) | **session lookup 失败**:`No saved session found with ID …`,exit 1。documented limitation | +| 有(sid 出自 worktree X) | 有(slug Y, X != Y) | 同上,session 跨 projectHash 不可寻 | + +跨 projectHash override 的语义(`--worktree` 在不同 worktree / 主 checkout 的 session 之间转移)需要 storage 锚定到 repo root 而非 cwd-derived projectHash,属于未来 Config 重构范畴。`persistStartupWorktreeSidecar` 内的 `overrodeResumedWorktree` 分支代码保留是为该重构落地后能自动生效,目前在生产路径不会触发。 + +#### D-2:`worktree.symlinkDirectories` 配置项 + +**schema:** + +```jsonc +{ + "worktree": { + "symlinkDirectories": ["node_modules", "dist", ".turbo"], + }, +} +``` + +- 类型:`string[]`,默认 `undefined`(不开启,opt-in) +- 顶层 namespace `worktree` 是新增的(在 `settingsSchema.ts` 中按字母序插在 `tools` 与 `ui` 之间) +- 路径**相对于主仓库根**,绝对路径或包含 `..` 的路径被路径遍历守卫拒绝 + +**作用范围:** 所有由通用层创建的 worktree,包括: + +- `EnterWorktreeTool`(Phase A) +- `AgentTool` `isolation: 'worktree'`(Phase B) +- `--worktree` CLI flag(Phase D-1) + +Arena 的 worktree 不走通用层,**不**受此配置影响。 + +**实现位置:** `GitWorktreeService.performPostCreationSetup()` —— 紧跟现有的 `configureHooksPath()`(Phase C 已建立的模式)。新增 `symlinkConfiguredDirectories()` 方法,遍历配置项调用 `fs.symlink(absSource, absDest, 'dir')`。 + +**错误处理(fail-open):** + +| 场景 | 行为 | +| ----------------------------- | ------------------------------ | +| 源目录不存在(ENOENT) | 静默跳过,debug log | +| 目标路径已存在(EEXIST) | 静默跳过,debug log(不覆盖) | +| 路径遍历(`../`、绝对路径等) | 拒绝该项,debug log warn | +| 其他 I/O 错误 | debug log warn,继续处理后续项 | + +worktree 创建本身**不会**因为 symlink 失败而中止 —— 与 `configureHooksPath()` 相同的"best-effort post-creation setup"原则。 + +#### D-3:PR 引用解析(`--worktree=#` / 全 URL) + +**支持形式:** + +| 形式 | 解析后的 PR 号 | +| --------------------------------------------------------------- | -------------- | +| `--worktree=#123` | 123 | +| `--worktree '#123'` | 123 | +| `--worktree https://github.com/foo/bar/pull/123` | 123 | +| `--worktree https://gh.enterprise.com/foo/bar/pull/123?baz=qux` | 123 | + +**slug 与分支命名:** + +- slug:`pr-`(特殊保留前缀,与用户 slug 区分) +- 分支:`worktree-pr-`(沿用 qwen-code 现有 `worktree-` 命名规则;不采用 claude-code 的 `pr-` 直接命名,避免与本地 `pr-` 分支冲突) + +**fetch 策略:** + +``` +git fetch origin pull//head +→ 用 FETCH_HEAD 作为新 worktree 的 base +``` + +不依赖 `gh` CLI —— 纯 git fetch,支持任何 GitHub 实例(公网或企业版),只要 `origin` 远程指向 GitHub。 + +**错误路径:** + +| 场景 | 错误消息 | +| ------------------------ | ---------------------------------------------------------------------------- | +| `origin` 远程缺失 | `--worktree=# requires an "origin" remote that points at GitHub.` | +| `git fetch` 失败 | `Failed to fetch PR #: PR may not exist or origin remote is unreachable.` | +| 网络超时(30s) | 同上,加 `(timeout)` | +| `origin` 远程不是 GitHub | 不做主动检查,由 `git fetch` 自然失败(PR 协议是 GitHub 特有的) | + +**与 D-2 的关系:** PR worktree **同样**应用 `symlinkDirectories`(用户期望在 PR 上立刻能跑测试,依赖目录需要复用)。 + +#### 影响文件 + +| 文件 | 变更类型 | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `packages/cli/src/config/config.ts` | yargs 新增 `--worktree` 选项;`CliArgs` 接口加 `worktree?: string \| boolean` | +| `packages/cli/src/gemini.tsx` | `loadCliConfig()` 之后、主循环之前调用新的 `setupStartupWorktree()` helper | +| `packages/cli/src/startup/worktreeStartup.ts` | 新建:`setupStartupWorktree()` 处理 slug 解析、PR fetch、sidecar 写入、cwd 切换 | +| `packages/cli/src/nonInteractiveCli.ts` | 复用同一 helper(已有 `restoreWorktreeContext` 注入逻辑,无须改) | +| `packages/cli/src/acp-integration/acpAgent.ts` | 复用同一 helper | +| `packages/core/src/services/gitWorktreeService.ts` | 新增 `parsePRReference()`、`fetchPullRequestRef()`、`symlinkConfiguredDirectories()`;`createUserWorktree()` 接受可选 `baseBranchRef` 参数 | +| `packages/cli/src/config/settingsSchema.ts` | 新增 `worktree.symlinkDirectories: string[]` 顶层项 | +| `packages/vscode-ide-companion/schemas/settings.schema.json` | 重新生成 | +| `docs/users/features/worktree.md` | 新增 Quick Start CLI flag 章节、Settings 表新增一行 | + +#### 安全与回滚 + +- **fail-open vs fail-close:** symlink / hooks 失败 **不** 中止 worktree 创建(同 Phase C 既定模式);PR fetch 失败 **中止** 启动(无 base ref 就无法创建 worktree);slug 校验失败 **中止** 启动(与 `EnterWorktreeTool` 一致)。 +- **path traversal:** `symlinkDirectories` 项必须解析后仍在 `repoRoot` 内,否则拒绝该项并 log。 +- **PR fetch 超时:** 30 秒硬超时,避免无响应的网络拖死启动。 +- **cwd 切换的副作用:** 切 `process.cwd()` 之后,相对路径(如 `--prompt-file ./foo.txt`)的解析会受影响。**对策:** 在切 cwd 之前先解析所有相对路径参数(具体在 `setupStartupWorktree()` 入口处做一次 normalize)。 + +#### 开放问题 -| 文件 | 变更类型 | -| -------------------------------------------------- | ------------------------------------------- | -| `packages/cli/src/args.ts` | 新增 `--worktree [name]` 参数 | -| `packages/cli/src/main.ts`(或启动入口) | 解析 `--worktree` 并在主循环前创建 worktree | -| `packages/core/src/services/gitWorktreeService.ts` | `createUserWorktree()` 后追加 symlink 逻辑 | -| `packages/core/src/config/`(settings schema) | 新增 `worktree.symlinkDirectories` 字段 | +1. **`--worktree-keep-on-exit`?** claude-code 没有,qwen-code 是否需要一个 CLI flag 让 Exit Dialog 默认选 keep?建议**先不加**,等用户反馈。 +2. **`worktree.symlinkDirectories` 是否需要 per-project override?** 当前 settings 已经支持 user/workspace/project 三级合并,无需特殊处理。 +3. **PR fetch 是否要拉取 `merge` ref(`pull//merge`,即与 base 合并后的 ref)而非 `head`?** claude-code 选 `head`,理由是用户通常想看 PR 的实际改动。沿用此选择。 --- @@ -266,9 +390,8 @@ _`worktree.symlinkDirectories` 配置项:_ 以下功能面向更特定的使用场景,当前阶段不纳入排期,待用户需求明确后再评估实现。 -| 功能 | 说明 | -| ----------------------- | ------------------------------------------------------------------------------------------- | -| sparse checkout | `worktree.sparsePaths` 配置项,大型 monorepo 只 checkout 指定路径,缩短创建时间和磁盘占用 | -| `.worktreeinclude` 文件 | 将 gitignore 的文件(`.env`、`secrets.json` 等)自动复制进 worktree | -| tmux 集成 | `--worktree --tmux` 在新 tmux 窗口启动 worktree 会话 | -| PR 引用解析 | `--worktree=#123` 自动 fetch PR 分支并基于它创建 worktree(依赖 Phase D `--worktree` 标志) | +| 功能 | 说明 | +| ----------------------- | ----------------------------------------------------------------------------------------- | +| sparse checkout | `worktree.sparsePaths` 配置项,大型 monorepo 只 checkout 指定路径,缩短创建时间和磁盘占用 | +| `.worktreeinclude` 文件 | 将 gitignore 的文件(`.env`、`secrets.json` 等)自动复制进 worktree | +| tmux 集成 | `--worktree --tmux` 在新 tmux 窗口启动 worktree 会话 | diff --git a/docs/e2e-tests/worktree-phase-d.md b/docs/e2e-tests/worktree-phase-d.md new file mode 100644 index 00000000000..4416077fc10 --- /dev/null +++ b/docs/e2e-tests/worktree-phase-d.md @@ -0,0 +1,748 @@ +# Worktree Phase D E2E Test Plan + +## Scope + +End-to-end verification of Phase D features against the local build at +`/Users/mochi/code/qwen-code/.claude/worktrees/tender-jemison-037f0a/dist/cli.js`. + +Phase D delivers three cross-cutting capabilities: + +- **D-1** — `--worktree [name]` CLI startup flag (bare / explicit slug / `=` form), + with `process.cwd()` + `Config.targetDir` switch and `WorktreeExitDialog` + reuse on exit +- **D-2** — `worktree.symlinkDirectories: string[]` settings key, applied in + `performPostCreationSetup()` so it covers `--worktree`, `EnterWorktreeTool`, + AND `AgentTool isolation: "worktree"` paths +- **D-3** — `--worktree=#` and `--worktree ` PR-reference forms, + via `git fetch origin pull//head` (no `gh` CLI dependency) + +## Binaries + +- **Local build (Phase 6 verification)**: `node /Users/mochi/code/qwen-code/.claude/worktrees/tender-jemison-037f0a/dist/cli.js` +- **Phase 4 dry-run baseline**: globally installed `qwen` + +For dry-runs the globally installed `qwen` is expected to fail Groups A / E / F +because the features don't exist yet — that's the validation that the plan +correctly detects implementation. + +### Baseline precondition for Group E + +Tests **E2** (`EnterWorktreeTool` symlink) and **E3** (`AgentTool isolation` +symlink) require **Phase A + B** to be present in the baseline — they exercise +the existing `enter_worktree` tool and `agent isolation: "worktree"` parameter +to confirm the symlink loop fires on those code paths too. + +The globally installed `qwen` may predate PR #4073 (Phase A+B, merged 2026-05-14) +and therefore lack these tools entirely. When that is the case, E2 / E3 cannot +validate "symlink absent because D-2 is absent" — they collapse to "tool +absent." Add this guard at the top of each: + +```bash +HAS_ENTER_WORKTREE=$($QWEN "list your tools and stop" --approval-mode yolo --output-format json 2>/dev/null \ + | jq -e '.[] | select(.type=="system") | .tools | index("enter_worktree")' >/dev/null && echo yes || echo no) +if [ "$HAS_ENTER_WORKTREE" != "yes" ]; then + echo "SKIP: enter_worktree absent in baseline — E2/E3 require Phase A+B" + exit 0 +fi +``` + +For Phase 6 (post-impl) verification the local build inherently contains +Phase A-C, so the guard is a no-op and the tests run in full. + +## Test environment template + +Each group runs in its own temp git repo and tmux session: + +```bash +TEST_DIR=$(mktemp -d -t qwen-wt-phd-XXXXXX) +TEST_DIR=$(cd "$TEST_DIR" && pwd -P) # resolve symlinks (macOS /var → /private/var) +cd "$TEST_DIR" +git init -q -b main +git config user.email t@e.com +git config user.name t +git config commit.gpgsign false +echo "hello" > README.md +git add README.md +git commit -q -m "initial" --no-verify + +PROJECT_ID=$(node -e "console.log(process.argv[1].replace(/[^a-zA-Z0-9]/g,'-'))" "$TEST_DIR") +QWEN="node /Users/mochi/code/qwen-code/.claude/worktrees/tender-jemison-037f0a/dist/cli.js" +``` + +PR-ref tests (Group F) additionally require a checked-out clone of a public +GitHub repo with at least one merged PR. Use this repo (qwen-code itself) as +the test target — PR `#4174` (Phase C) is a guaranteed-present reference. + +--- + +## Group A: `--worktree` flag basic forms + +**Mode:** headless, `--approval-mode yolo`, `--output-format json` + +### A1: bare `--worktree` (auto-slug) + +```bash +$QWEN --worktree "say hello and stop" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/a1.out + +# A `worktree_started` system event is emitted at startup. The `notice` +# field contains the slug (auto-generated `adj-noun-XXXXXX`) inside the +# rendered text. Use `jq -e` so a missing event is a non-zero exit +# (instead of silent `null`). +jq -e '.[] | select(.type=="system" and .subtype=="worktree_started") | .data.notice | test("\"[a-z]+-[a-z]+-[0-9a-f]{6}\"")' < /tmp/a1.out + +# The init system message's `cwd` should also point inside the worktree. +jq -e '.[] | select(.type=="system" and .subtype=="init") | .cwd | test("/\\.qwen/worktrees/[a-z]+-[a-z]+-[0-9a-f]{6}$")' < /tmp/a1.out + +ls -d "$TEST_DIR/.qwen/worktrees/"* +``` + +**Expected (post-impl):** + +- `worktree_started` event with `.data.notice` containing the auto slug +- Init `.cwd` ends with `.qwen/worktrees/` +- Exactly one worktree directory under `.qwen/worktrees/` +- Branch named `worktree-` exists (`git branch | grep worktree-`) + +**Expected (pre-impl baseline):** yargs rejects `--worktree` with +"Unknown argument" error and exit code != 0. + +### A2: `--worktree my-feature` (explicit slug) + +```bash +$QWEN --worktree my-feature "say hello and stop" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/a2.out + +ls -d "$TEST_DIR/.qwen/worktrees/my-feature" +git -C "$TEST_DIR" branch | grep "worktree-my-feature" +``` + +**Expected (post-impl):** worktree dir `my-feature/` and branch +`worktree-my-feature` both exist. + +### A3: `--worktree=my-feature` (= form) + +Identical to A2 with `=` form. Cleanup between A2 and A3 required (different +TEST_DIR). + +```bash +$QWEN --worktree=my-feature "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/a3.out +``` + +**Expected (post-impl):** same as A2. + +### A4: invalid slug rejected before any git operation + +```bash +$QWEN --worktree "../escape" "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/a4.out +echo "exit=$?" + +ls "$TEST_DIR/.qwen/worktrees/" 2>/dev/null +``` + +**Expected (post-impl):** + +- Process exits with non-zero status +- Stderr or final result message mentions "invalid slug" / "not allowed" +- `.qwen/worktrees/` directory does not exist (worktree creation never started) + +### A5: not a git repository → fail-close + +```bash +NON_GIT=$(mktemp -d) +cd "$NON_GIT" +$QWEN --worktree "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/a5.out +echo "exit=$?" +``` + +**Expected (post-impl):** exit != 0, message mentions "not a git repository" +or "git init". + +--- + +## Group B: cwd + sidecar after `--worktree` + +### B1: sidecar written with all six fields + +```bash +SESSION_ID=$(uuidgen) +$QWEN --worktree b1-test --session-id "$SESSION_ID" "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/b1.out + +SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION_ID.worktree.json +jq '.slug, .worktreePath, .worktreeBranch, .originalCwd, .originalBranch, .originalHeadCommit' \ + < "$SIDECAR" +``` + +**Expected:** + +- `slug = "b1-test"` +- `worktreePath` ends with `.qwen/worktrees/b1-test` +- `worktreeBranch = "worktree-b1-test"` +- `originalCwd` = `$TEST_DIR` (resolved) +- `originalBranch = "main"` +- `originalHeadCommit` matches `[0-9a-f]{40}` + +### B2: `process.cwd()` switched at startup + +```bash +$QWEN --worktree b2-test "run the shell tool with command 'pwd', then stop" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/b2.out + +# Extract the shell tool's stdout from the user-message tool_result +jq -r '.[] | select(.type=="user") | .message.content[] | select(.tool_use_id != null) | .content' \ + < /tmp/b2.out | head -5 +``` + +**Expected (post-impl):** the `pwd` output equals `$TEST_DIR/.qwen/worktrees/b2-test`. + +### B3: `Config.targetDir` switched (Footer / status payload) + +```bash +$QWEN --worktree b3-test "run the shell tool with command 'pwd && git rev-parse --abbrev-ref HEAD', then stop" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/b3.out + +jq -r '.[] | select(.type=="user") | .message.content[] | select(.tool_use_id != null) | .content' \ + < /tmp/b3.out +``` + +**Expected (post-impl):** branch is `worktree-b3-test` AND working directory +is inside the worktree. + +--- + +## Group C: `--worktree` × `--resume` precedence + +### C1: `--worktree` wins over saved sidecar (different slug) + +```bash +# Run 1: create a session with worktree "first" +SESSION_ID=$(uuidgen) +$QWEN --worktree first --session-id "$SESSION_ID" "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/c1-run1.out + +# Run 2: resume the same session but request a different worktree +$QWEN --resume "$SESSION_ID" --worktree second "say hi again" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/c1-run2.out + +# Sidecar should now point at "second" +SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION_ID.worktree.json +jq -r '.slug' < "$SIDECAR" + +# Both worktree dirs should exist on disk (first was never removed, just unlinked) +ls -d "$TEST_DIR/.qwen/worktrees/"* +``` + +**Expected (post-impl):** + +- Sidecar `.slug` = `"second"` +- Both `first/` and `second/` directories exist +- Run 2's stderr or init `worktree_overridden` message mentions "--worktree + overrides the resumed session's worktree" + +### C2: stale sidecar (manually deleted dir) + `--worktree` → fresh worktree + +```bash +SESSION_ID=$(uuidgen) +$QWEN --worktree c2 --session-id "$SESSION_ID" "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/c2-run1.out + +rm -rf "$TEST_DIR/.qwen/worktrees/c2" # simulate user-deleted dir + +$QWEN --resume "$SESSION_ID" --worktree c2-fresh "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/c2-run2.out + +ls -d "$TEST_DIR/.qwen/worktrees/"* +``` + +**Expected (post-impl):** only `c2-fresh/` exists; sidecar updated to `c2-fresh`. + +--- + +## Group D: WorktreeExitDialog regression (`--worktree`-started session) + +**Mode:** interactive (tmux). Verifies Phase C dialog still triggers when the +worktree was created by the CLI flag rather than `EnterWorktreeTool`. + +### D1: 2x Ctrl+C → dialog appears + +```bash +tmux new-session -d -s d1 -x 200 -y 50 \ + "cd $TEST_DIR && $QWEN --worktree d1-test --approval-mode yolo" +sleep 3 + +# Verify worktree is active (Footer indicator) +tmux capture-pane -t d1 -p -S -50 | grep -q "⎇ worktree-d1-test" + +# Send Ctrl+C twice +tmux send-keys -t d1 C-c +sleep 0.3 +tmux send-keys -t d1 C-c +sleep 1 + +tmux capture-pane -t d1 -p -S -50 | grep -E "Active worktree|Keep worktree|Remove worktree" +tmux kill-session -t d1 +``` + +**Expected (post-impl):** dialog text "Active worktree: \"d1-test\" …" and the +three radio options appear. + +### D2: Dialog → Cancel → session stays alive + +```bash +tmux new-session -d -s d2 -x 200 -y 50 \ + "cd $TEST_DIR && $QWEN --worktree d2-test --approval-mode yolo" +sleep 3 +tmux send-keys -t d2 C-c; sleep 0.3; tmux send-keys -t d2 C-c; sleep 1 + +# Navigate to "Cancel" (third option) and select +tmux send-keys -t d2 Down Down Enter +sleep 1 + +tmux capture-pane -t d2 -p -S -10 | grep -q "Type your message" +ls -d "$TEST_DIR/.qwen/worktrees/d2-test" # still exists +tmux kill-session -t d2 +``` + +**Expected (post-impl):** prompt input reappears; worktree dir is still on disk. + +### D3: Dialog → Remove → worktree + branch + sidecar all gone + +```bash +SESSION_ID=$(uuidgen) +tmux new-session -d -s d3 -x 200 -y 50 \ + "cd $TEST_DIR && $QWEN --worktree d3-test --session-id $SESSION_ID --approval-mode yolo" +sleep 3 +tmux send-keys -t d3 C-c; sleep 0.3; tmux send-keys -t d3 C-c; sleep 1 +tmux send-keys -t d3 Down Enter # select "Remove worktree and branch" +sleep 3 +tmux kill-session -t d3 + +ls "$TEST_DIR/.qwen/worktrees/d3-test" 2>/dev/null && echo "FAIL: dir exists" +git -C "$TEST_DIR" branch | grep "worktree-d3-test" && echo "FAIL: branch exists" +test ! -f ~/.qwen/projects/$PROJECT_ID/chats/$SESSION_ID.worktree.json && echo "PASS: sidecar gone" +``` + +**Expected (post-impl):** dir, branch, and sidecar all removed. + +--- + +## Group E: `worktree.symlinkDirectories` + +**Mode:** headless. Settings configured via temp settings file. + +### Setup template + +```bash +mkdir -p "$TEST_DIR/node_modules" +echo "package.json" > "$TEST_DIR/node_modules/.placeholder" +mkdir -p "$TEST_DIR/.qwen" +cat > "$TEST_DIR/.qwen/settings.json" <<'EOF' +{ + "worktree": { + "symlinkDirectories": ["node_modules"] + } +} +EOF +``` + +### E1: `--worktree` path applies symlink + +```bash +$QWEN --worktree e1-test "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /dev/null + +ls -la "$TEST_DIR/.qwen/worktrees/e1-test/node_modules" +readlink "$TEST_DIR/.qwen/worktrees/e1-test/node_modules" +``` + +**Expected (post-impl):** `node_modules` inside the worktree is a symlink +pointing to `$TEST_DIR/node_modules`. + +### E2: `EnterWorktreeTool` path applies symlink + +```bash +$QWEN "use enter_worktree to create a worktree named e2-test, then stop" \ + --approval-mode yolo --output-format json 2>/dev/null > /dev/null + +readlink "$TEST_DIR/.qwen/worktrees/e2-test/node_modules" +``` + +**Expected (post-impl):** same symlink target. + +### E3: AgentTool isolation path applies symlink + +Requires a sub-agent definition. Use the built-in fork mechanism: + +```bash +$QWEN "use the agent tool with subagent_type='general-purpose', isolation='worktree', description='check node_modules', prompt='run pwd and ls -la node_modules then exit'" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/e3.out + +# Extract agent worktree dir from result message +jq -r '.[] | select(.type=="assistant") | .message.content[] | select(.type=="tool_use") | .input' \ + < /tmp/e3.out | head -5 + +# After execution find the agent-<7hex> worktree +ls -la "$TEST_DIR/.qwen/worktrees/"agent-*/node_modules 2>/dev/null | head -3 +``` + +**Expected (post-impl):** symlink exists inside the `agent-` worktree +(unless auto-cleaned because there were no changes — in that case the +"no changes" path doesn't validate symlink behavior, escalate to a forced +change test). + +### E4: missing source dir → silently skipped, worktree still created + +```bash +cat > "$TEST_DIR/.qwen/settings.json" <<'EOF' +{ "worktree": { "symlinkDirectories": ["does-not-exist"] } } +EOF + +$QWEN --worktree e4-test "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/e4.out +ls -d "$TEST_DIR/.qwen/worktrees/e4-test" +ls "$TEST_DIR/.qwen/worktrees/e4-test/does-not-exist" 2>/dev/null && echo "UNEXPECTED" +``` + +**Expected (post-impl):** worktree directory exists, the missing entry is +not created inside it, process exit = 0. + +### E5: existing dest → silently skipped, no overwrite + +```bash +# Pre-create a worktree at expected slug then re-create — this is contrived +# because Phase D paths should be fresh, but it exercises the EEXIST guard. +mkdir -p "$TEST_DIR/.qwen/worktrees/e5-test/node_modules" +echo "preexisting" > "$TEST_DIR/.qwen/worktrees/e5-test/node_modules/.marker" + +# Force re-creation via EnterWorktreeTool (CLI would refuse "already exists") +$QWEN "use enter_worktree with name='e5-test' to retry" --approval-mode yolo 2>/dev/null +# either: tool errors out cleanly, OR symlink is skipped — both acceptable +test -f "$TEST_DIR/.qwen/worktrees/e5-test/node_modules/.marker" && echo "PASS: not overwritten" +``` + +**Expected (post-impl):** preexisting `.marker` survives; no symlink replaces +the dir. + +### E6: absolute path / `../` → rejected + +```bash +cat > "$TEST_DIR/.qwen/settings.json" <<'EOF' +{ "worktree": { "symlinkDirectories": ["/etc", "../escape"] } } +EOF + +$QWEN --worktree e6-test "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/e6.out +ls "$TEST_DIR/.qwen/worktrees/e6-test/" | head -10 +``` + +**Expected (post-impl):** worktree exists; neither `etc` nor `escape` linked +inside it; debug log carries warn lines. + +--- + +## Group F: PR reference + +**Mode:** headless. Requires `origin` remote pointing at a public GitHub repo. + +### Setup template + +```bash +# Use qwen-code itself as the test repo +TEST_DIR=$(mktemp -d -t qwen-wt-phd-pr-XXXXXX) +TEST_DIR=$(cd "$TEST_DIR" && pwd -P) +cd "$TEST_DIR" +git clone --depth 1 https://github.com/QwenLM/qwen-code.git . +PROJECT_ID=$(node -e "console.log(process.argv[1].replace(/[^a-zA-Z0-9]/g,'-'))" "$TEST_DIR") +``` + +### F1: `--worktree=#4174` parses + fetches + +```bash +$QWEN --worktree=#4174 "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/f1.out + +ls -d "$TEST_DIR/.qwen/worktrees/pr-4174" +git -C "$TEST_DIR/.qwen/worktrees/pr-4174" rev-parse --abbrev-ref HEAD +``` + +**Expected (post-impl):** + +- Worktree dir `pr-4174/` exists +- HEAD branch = `worktree-pr-4174` +- The branch's tip resolves (git log -1) without error + +### F2: full URL form + +```bash +$QWEN --worktree "https://github.com/QwenLM/qwen-code/pull/4174" "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/f2.out + +ls -d "$TEST_DIR/.qwen/worktrees/pr-4174" +``` + +**Expected (post-impl):** same as F1. + +### F3: missing `origin` remote → fail-close + +```bash +cd "$TEST_DIR" && git remote remove origin +$QWEN --worktree=#4174 "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/f3.out +echo "exit=$?" +``` + +**Expected (post-impl):** exit != 0; message mentions `origin` remote. + +### F4: invalid PR number → fail-close + +```bash +$QWEN --worktree=#999999999 "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/f4.out +echo "exit=$?" +``` + +**Expected (post-impl):** exit != 0; message mentions "Failed to fetch PR". +30-second timeout cap respected (test runtime < 35s). + +### F5: malformed `#abc` falls through to slug validation + +```bash +$QWEN --worktree=#abc "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/f5.out +echo "exit=$?" +``` + +**Expected (post-impl):** treated as literal slug `#abc`, rejected by +`validateUserWorktreeSlug` because `#` is not allowed. Exit != 0. + +### F6: PR worktree gets symlinks too (cross-cut with E) + +```bash +cat > "$TEST_DIR/.qwen/settings.json" <<'EOF' +{ "worktree": { "symlinkDirectories": ["node_modules"] } } +EOF +mkdir -p "$TEST_DIR/node_modules" && echo x > "$TEST_DIR/node_modules/.marker" + +$QWEN --worktree=#4174 "say hi" --approval-mode yolo --output-format json 2>/dev/null > /dev/null +readlink "$TEST_DIR/.qwen/worktrees/pr-4174/node_modules" +``` + +**Expected (post-impl):** symlink target = `$TEST_DIR/node_modules`. + +--- + +## Group G: Integration + edge cases + +### G1: full lifecycle — start → write → Keep → resume + +> **Pre-impl note:** Against the baseline this test exits before `sleep 3` +> finishes (yargs rejects `--worktree` immediately and the tmux pane dies). +> The `capture-pane` call then errors with "can't find pane". This is +> expected — record as PASS-by-rejection. Wrap captures with `|| true` for +> the dry-run, or skip G1 entirely in baseline mode. + +```bash +SESSION_ID=$(uuidgen) +tmux new-session -d -s g1 -x 200 -y 50 \ + "cd $TEST_DIR && $QWEN --worktree g1-test --session-id $SESSION_ID --approval-mode yolo 2>&1 | tee /tmp/g1-stderr.out" +sleep 3 +tmux send-keys -t g1 "use the write_file tool to create file 'work.txt' with content 'phase d test'" +sleep 0.3; tmux send-keys -t g1 Enter +sleep 8 + +tmux send-keys -t g1 C-c; sleep 0.3; tmux send-keys -t g1 C-c; sleep 1 +tmux send-keys -t g1 Enter # default = "Keep" +sleep 2 +tmux kill-session -t g1 + +# File survived +cat "$TEST_DIR/.qwen/worktrees/g1-test/work.txt" + +# Resume reattaches +tmux new-session -d -s g1b -x 200 -y 50 \ + "cd $TEST_DIR && $QWEN --resume $SESSION_ID --approval-mode yolo" +sleep 4 +tmux capture-pane -t g1b -p -S -50 | grep -E "⎇ worktree-g1-test|Resumed" +tmux kill-session -t g1b +``` + +**Expected (post-impl):** + +- `work.txt` inside the worktree contains the written content +- Resumed session Footer shows `⎇ worktree-g1-test (g1-test)` +- INFO history item or `` mentions "Resumed" + +### G2: relative path arg resolved before cwd switch + +```bash +# Create an mcp config in TEST_DIR and reference it relatively. +# --mcp-config takes a file path; if the test plan path is resolved AFTER +# the --worktree cwd switch, the file won't be found inside the worktree +# and the CLI will error out. If resolved BEFORE the switch (correct), the +# file is loaded from TEST_DIR. +cat > "$TEST_DIR/mcp.json" <<'EOF' +{ "mcpServers": {} } +EOF +cd "$TEST_DIR" + +$QWEN --worktree g2-test --mcp-config ./mcp.json "say hi" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/g2.out +echo "exit=$?" +jq -r '.[] | select(.type=="result") | .result' < /tmp/g2.out | head -3 +``` + +**Expected (post-impl):** exit = 0; the model responds normally (the empty +mcp config means no MCP servers but no error either). + +**Expected (pre-impl baseline):** yargs rejects `--worktree` (the test +cannot distinguish "worktree flag missing" from "mcp config resolution +broken" until the flag itself exists). + +--- + +## Run order + parallelism + +| Group | Mode | Runtime | Parallel-safe? | +| ----- | ------------ | ------- | ---------------------------- | +| A | headless | ~30s | yes (own TEST_DIR) | +| B | headless | ~20s | yes | +| C | headless | ~40s | yes | +| D | tmux | ~30s | yes (own session name) | +| E | headless | ~60s | yes | +| F | headless+net | ~60s | NO — shares the GitHub clone | +| G | mixed | ~60s | yes | + +Run A/B/C/D/E/G in parallel; F serially after the clone setup. + +## Reproduction report + +### Phase 4 dry-run — baseline `qwen` v0.15.11 (2026-05-20) + +Runtime: 3 parallel `test-engineer` agents, ~7 minutes total. Baseline lacks +both Phase D (expected) and Phase A+B (older binary than expected — see +E2/E3 caveat). + +| Group | Result | Notes | +| -------------------------------- | ---------- | ------------------------------------------------------------------------------------- | +| A1 (bare flag) | ✅ | yargs `Unknown argument: worktree`, exit 1 | +| A2 (explicit slug) | ✅ | same | +| A3 (= form) | ✅ | same | +| A4 (invalid slug) | ✅ | yargs rejects before slug validation | +| A5 (non-git dir) | ✅ | same | +| B1 (sidecar fields) | ✅ | sidecar correctly absent; jq selector valid against sample data | +| B2 (cwd switch) | ✅ | shell-tool `tool_result.content` jq selector verified against real output | +| B3 (targetDir switch) | ✅ | same selector | +| C1 (--worktree beats sidecar) | ✅ | both runs exit 1, no sidecar | +| C2 (stale sidecar + fresh) | ✅ | same | +| E1 (--worktree symlink) | ✅ | flag rejected, no symlink — pre-impl confirmed | +| E2 (EnterWorktree symlink) | ⚠️ N/A | baseline lacks `enter_worktree` tool (older than PR #4073); guard now skips this case | +| E3 (AgentTool isolation symlink) | ⚠️ N/A | baseline `agent` schema silently drops `isolation` param; guard skips | +| E4 (missing source skip) | ✅ | flag rejected | +| E5 (existing dest not overwrite) | ⚠️ trivial | preexisting `.marker` survived but only because tool couldn't run | +| E6 (path traversal reject) | ✅ | flag rejected, no symlinks | +| F1 (--worktree=#4174 fetch) | ✅ | `Unknown argument: worktree`, no network call | +| F2 (full URL form) | ✅ | same | +| F3 (missing origin) | ✅ | rejected before git check | +| F4 (invalid PR number) | ✅ | rejected before fetch | +| F5 (`#abc` malformed) | ✅ | same | +| F6 (PR + symlinkDirs) | ✅ | same | +| G1 (lifecycle tmux) | ⚠️ partial | tmux pane dies on flag rejection; record-by-exit-code works | +| G2 (relative path) | ✅ | (after switching to `--mcp-config ./mcp.json`) yargs rejects worktree first | + +**Conclusion:** test scripts are fundamentally sound. 19 / 24 cases cleanly +detect pre-impl baseline; 3 cases (E2/E3/E5) need the baseline to include +Phase A+B (which the local Phase 6 build will provide); 2 cases (G1/G2) had +script bugs that are now fixed. **Ready to proceed to Phase 5 +implementation.** + +### Phase 6 verification — local build + +**Binary**: `node /Users/mochi/code/qwen-code/.claude/worktrees/tender-jemison-037f0a/dist/cli.js` +**Date**: 2026-05-20 +**Scope**: Groups A, B, C, E, F, G (6 parallel `test-engineer` agents) + +| Group | Result | Notes | +| ---------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 (bare flag) | ✅ (with doc tip) | yargs consumes the next positional as the slug value when user passes `qwen --worktree "say hi"`; quickstart now tells users to use `=` form or put the prompt before the flag. Auto-slug feature itself confirmed via `qwen --worktree --approval-mode yolo "say hi"` → slug `bright-elm-8a4c12`, init `.cwd` ends with `.qwen/worktrees/`. | +| A2 (explicit slug) | ✅ | dir `.qwen/worktrees/my-feature` + branch `worktree-my-feature` | +| A3 (= form) | ✅ | identical to A2 | +| A4 (invalid slug) | ✅ | exit=1, message: `Worktree name may only contain letters, digits, dots, underscores, and hyphens.`, no worktree dir | +| A5 (non-git dir) | ✅ | exit=1, message: `not a git repository. Run \`git init\` first or relaunch from inside one.` | +| B1 (sidecar fields) | ✅ | All 6 fields present and correct; sidecar lives under worktree projectHash as designed | +| B2 (cwd switch) | ✅ | `pwd` inside shell tool returned worktree path exactly | +| B3 (branch + cwd) | ✅ | `pwd` = worktree path, `git rev-parse --abbrev-ref HEAD` = `worktree-b3-test` | +| C1 (cross-slug override) | ❌ → **known limitation** | Sessions are bound to `projectHash(cwd)`; `--worktree second --resume ` can't find the session. Documented in user docs Limitations. A future Config refactor (anchor storage at repo root) would lift this. | +| C2 (stale sidecar + new worktree) | ❌ → **same root cause** | Same architectural constraint. | +| E1 (`--worktree` symlink) | ✅ | `node_modules` symlinked into the new worktree | +| E2 (`enter_worktree` symlink) | ✅ | same code path via `createUserWorktree` | +| E3 (agent isolation symlink) | ⚠️ test-setup | model committed `node_modules` (because the agent guard refused dirty state); EEXIST guard then correctly skipped the symlink. Code path is correct; for a clean E3 the test plan needs to pre-`.gitignore` `node_modules`. | +| E4 (missing source skip) | ✅ | worktree created, no entry, exit 0 | +| E5 (existing dest no overwrite) | ✅ | preexisting marker survived | +| E6 (absolute / `..` rejected) | ✅ | neither path linked | +| F1 (`--worktree=#4174` fetch) | ✅ | worktree dir `pr-4174/`, branch `worktree-pr-4174`, tip commit `8f4fe8e feat(cli): per-turn /diff…`; local-remote substitute (sandbox blocks real GitHub) | +| F2 (full URL form) | ✅ | same result; URL parsed → PR #4174 → local origin fetch succeeded | +| F3 (missing origin) | ✅ | exit=1 in 2s; message mentions adding `origin` remote | +| F4 (invalid PR #999999999) | ✅ | exit=1 in 2s; "PR does not exist on origin"; well within 35s cap | +| F5 (malformed `#abc`) | ✅ | slug validation rejects `#` | +| F6 (PR worktree + symlinks) | ✅ | symlink `pr-4174/node_modules` → `$TEST_DIR/node_modules` confirmed | +| G1.a (start + write + Keep) | ✅ | TUI flow, Footer indicator, dialog options, file persists | +| G1.b (`--resume … --worktree foo`) | ❌ → **fixed in this PR** | Original: `--worktree: Worktree already exists at …`. Phase 6 fix added the re-attach branch in `setupStartupWorktree`. Verified post-fix via smoke test (`--worktree foo` twice → second emits the `worktree_started` notice, no error) + new unit tests in `worktreeStartup.test.ts`. | +| G2 (relative `--mcp-config`) | ❌ → **fixed in this PR** | Original: exit=52, `Invalid MCP configuration … is not valid JSON`. Phase 6 fix normalizes path-taking argv fields (`mcpConfig`, `openaiLoggingDir`, `jsonFile`, `inputFile`, `telemetryOutfile`, `includeDirectories`) against the launch cwd BEFORE `setupStartupWorktree` chdirs. Verified post-fix via smoke test (`--worktree foo --mcp-config ./mcp.json` → model responds normally). | + +**Phase 6 net result:** 22 / 24 cases passed post-fix; 2 cases (C1/C2) hit an +architectural limitation now documented; 1 case (E3) is a test-setup quirk, +not an implementation issue. **Ready for Phase 7 code review.** + +### Fix references (Phase 6 fixes that landed in this PR) + +| Fix | File | Change | +| ----------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Re-attach to existing worktree (G1.b) | `packages/cli/src/startup/worktreeStartup.ts` | Added pre-create check: if dir is a registered worktree on the expected branch, skip create + chdir | +| `getRegisteredWorktreeBranch()` helper | `packages/core/src/services/gitWorktreeService.ts` | Probes `git rev-parse --abbrev-ref HEAD` against the candidate path | +| Path normalization before chdir (G2) | `packages/cli/src/gemini.tsx` | Resolves `mcpConfig`, `openaiLoggingDir`, `jsonFile`, `inputFile`, `telemetryOutfile`, `includeDirectories` against launch cwd when `--worktree` is set | +| Documentation: yargs flag ordering tip + Limitations update | `docs/users/features/worktree.md` | Quick Start tip + new Limitations bullets (cross-slug, path-arg behavior) | +| Unit tests for re-attach | `packages/cli/src/startup/worktreeStartup.test.ts` | Added 2 tests: happy re-attach + "different branch occupies slot" guard | + +**Phase 6 Group F network note**: The sandbox blocks `git fetch` to `https://github.com` with HTTP 403. F1/F2/F4/F6 were retested against a local bare repo (`git init --bare`) seeded with `refs/pull/4174/head` pointing at a commit whose message is `feat(cli): per-turn /diff with interactive dialog (#4277)`. F3 and F5 are network-independent and were verified directly. The local-remote substitute fully exercises the parsing + fetch + worktree-creation code path. + +--- + +## Reproduction report — Phase 4 dry-run (Groups F + G), 2026-05-20 + +**Binary**: `qwen` (globally installed, v0.15.11 at `/Users/mochi/.nvm/versions/node/v22.21.1/bin/qwen`) +**Override**: `QWEN="qwen"` + +### Results table + +| Test ID | Result | Evidence | Fix suggestion | +| ------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| F1 `--worktree=#4174` | PASS | `Unknown argument: worktree`, exit=1 | None — expected baseline failure | +| F2 `--worktree ` | PASS | `Unknown argument: worktree`, exit=1 | None — expected baseline failure | +| F3 missing origin | PASS | `Unknown argument: worktree`, exit=1 — yargs rejected before any git op | None | +| F4 invalid PR #999999999 | PASS | `Unknown argument: worktree`, exit=1 | None | +| F5 malformed `#abc` | PASS | `Unknown argument: worktree`, exit=1 | None | +| F6 PR + symlinkDirs | PASS | `Unknown argument: worktree`, exit=1 | None | +| G1 lifecycle (tmux) | PASS | `Unknown argument: worktree` emitted to stdout captured in `/tmp/g1_raw.out`; tmux session exited immediately, pane was already dead by capture time | SCRIPT-BUG: see note below | +| G2 relative path | PASS | `Unknown arguments: worktree, prompt-file, promptFile`, exit=1 | SCRIPT-BUG: see note below | + +### Observed behavior (all cases) + +Every invocation of `--worktree` (bare, `=` form, `#` form, full URL, combined with `--prompt-file`) was rejected at the yargs argument-parsing layer with exit code 1 before any application logic ran. The exact error strings are: + +- `Unknown argument: worktree` (single unknown arg) +- `Unknown arguments: worktree, prompt-file, promptFile` (G2: both `--worktree` and `--prompt-file` are unknown, listed together) + +No git operations, no network calls, no filesystem writes occurred in any test. + +### Expected behavior + +Identical rejection — this is the correct pre-implementation baseline. All 8 tests PASS in the dry-run sense (the plan correctly detects that the features do not exist). + +### Key context + +The failure mode is uniformly at the yargs layer, not downstream. This confirms the test plan's detection strategy is sound: once `--worktree` is wired into yargs, these tests will stop failing at this layer and will instead exercise the actual implementation paths (F1-F6 will hit git fetch, G1 will hit the TUI lifecycle, G2 will hit `--prompt-file` resolution). + +### SCRIPT-BUG notes for the test plan + +**G1 (tmux):** The tmux session command pipes through `tee` with a subshell `echo 'PROC_EXIT='$?` that captures the exit of `tee`, not of `qwen`. When the process exits instantly (as with an Unknown argument error), the session terminates before `sleep 3` finishes and the pane name `g1dry` is gone by the time `tmux capture-pane` runs, producing `can't find pane: g1dry`. Fix: use `|| true` after `tmux capture-pane`, or add a `|| sleep 0` guard; better still, for the baseline-fail case redirect stderr+stdout to a file outside tmux and check the file directly (as done here via `tee /tmp/g1_raw.out`). + +**G2 (`--prompt-file`):** The test plan uses `--prompt-file ./relative.txt` as a combined test with `--worktree`. In the baseline, `--prompt-file` is also an unknown argument (it does not exist in v0.15.11 yargs schema either — the flag is `--prompt-interactive` / `-p`). The error lists both unknown args together. The plan should note that `--prompt-file` will need to be implemented alongside `--worktree`, or use an existing flag (e.g. pipe via stdin or use `--prompt`) for the relative-path resolution test. diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index 3cbc9b5363d..17d10f21c05 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -16,6 +16,7 @@ export default { }, 'approval-mode': 'Approval Mode', 'auto-mode': 'Auto Mode', + worktree: 'Worktrees', mcp: 'MCP', lsp: 'LSP (Language Server Protocol)', 'token-caching': 'Token Caching', diff --git a/docs/users/features/worktree.md b/docs/users/features/worktree.md new file mode 100644 index 00000000000..1157b9cfc01 --- /dev/null +++ b/docs/users/features/worktree.md @@ -0,0 +1,345 @@ +# Worktrees + +> Isolate experimental work in a temporary [git worktree](https://git-scm.com/docs/git-worktree) without leaving your current session. Useful when the model is about to make wide-ranging edits you want to keep separate from your main checkout, or when you want a subagent to work in a sandbox of its own. + +## Quick Start + +### Start the session inside a worktree (`--worktree` flag) + +If you know up front that the entire session should run inside a worktree, pass `--worktree` at launch: + +```bash +# Auto-generated slug (e.g. tender-jemison-037f0a) +qwen --worktree + +# Explicit name +qwen --worktree my-feature + +# `=` form (recommended when also passing a positional prompt — see tip below) +qwen --worktree=my-feature + +# PR reference — fetches refs/pull//head from `origin` +qwen --worktree=#4174 +qwen --worktree https://github.com/QwenLM/qwen-code/pull/4174 + +# Continue a previous --worktree session — re-attaches to the existing dir +qwen --resume --worktree=my-feature +``` + +> **Tip — bare `--worktree` followed by a positional prompt is ambiguous.** Because `--worktree` takes an optional value, `qwen --worktree "say hi"` makes yargs consume `"say hi"` as the slug (and reject it because of the space). Use one of: +> +> - `qwen --worktree=my-feature "say hi"` (always works — explicit slug via `=`) +> - `qwen "say hi" --worktree` (positional first, flag at the end → auto slug) +> - `qwen --worktree --approval-mode yolo "say hi"` (any flag between them anchors the bare form) + +> **Tip — `qwen --resume --worktree foo` (no session ID) shows an empty picker on first use.** The picker scopes to the chosen worktree's session storage; sessions started outside that worktree are not listed. To resume a session that was started inside `foo`, use `qwen --resume --worktree foo` directly — the CLI re-attaches to the existing `foo/` directory rather than re-creating it. + +`process.cwd()` and the model's workspace are switched to the worktree before the first turn runs. Exit with `Ctrl+C` twice and the [Exit Dialog](#exit-dialog-ctrlc--ctrld) prompts to keep or remove the worktree. + +The `--worktree` flag cannot be combined with `--acp`/`--experimental-acp` — for ACP hosts (like Zed), pass the worktree path as the `cwd` of the `loadSession`/`newSession` request instead. + +### Or ask mid-session + +Alternatively, ask Qwen Code in plain language to create a worktree from inside an existing session: + +```text +> start a worktree called experiment-a +Worktree experiment-a created on branch worktree-experiment-a +.qwen/worktrees/experiment-a +``` + +From this point on, the model routes every file edit and shell command through `.qwen/worktrees/experiment-a/`. Your original working directory is untouched. + +When you are done: + +```text +> exit the worktree and remove it +Removed worktree experiment-a (branch worktree-experiment-a) +``` + +If you want to come back later, ask to exit with the worktree kept on disk instead: + +```text +> exit the worktree but keep it +Kept worktree experiment-a at .qwen/worktrees/experiment-a +``` + +## When Worktrees Are Used + +Worktrees are activated in four independent paths: + +| Trigger | What happens | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| You launch with `--worktree` | The CLI creates the worktree before any model turn runs and chdirs the session into it. PR forms (`#N`, full URL) fetch first. | +| You explicitly ask for a worktree mid-session | Model calls `enter_worktree`; subsequent file edits go inside it. | +| You explicitly ask to leave | Model calls `exit_worktree` with `keep` or `remove`. | +| Model spawns a sub-agent with isolation enabled | A throwaway worktree (`agent-`) is created automatically and cleaned up if the agent has no diffs. | + +The two mid-session tools (`enter_worktree` / `exit_worktree`) are deliberately gated behind explicit phrasing — saying "fix this bug" or "create a branch" will **not** trigger them. You must say something like "use a worktree", "start a worktree", or "in a worktree". The `--worktree` CLI flag has no such guard; it always creates one when present. + +## What Gets Created + +Every Qwen-managed worktree is placed under your project's `.qwen` directory: + +``` +/.qwen/worktrees// # Working directory + ↳ branch worktree- # Created off your current branch +``` + +- **Slug** — letters, digits, dot, underscore, hyphen; max 64 chars. If you don't specify a name, an `--<6hex>` slug is auto-generated (e.g. `tender-jemison-037f0a`). PR references produce `pr-`. +- **Branch** — always `worktree-`, branched from whichever branch you have checked out when you ask for the worktree (not necessarily the main working tree's `HEAD`). For PR worktrees the branch is `worktree-pr-` and is based on `FETCH_HEAD` (the PR's tip on the GitHub side) rather than your local branch. +- **Hooks** — the worktree's `core.hooksPath` is automatically pointed at the main repo's `.husky/` (preferred) or `.git/hooks/` so commits inside the worktree still trigger your existing pre-commit / commit-msg hooks. +- **Optional symlinks** — directories listed in `worktree.symlinkDirectories` (see [Settings](#settings)) are symlinked from the main repo into the new worktree so heavy dirs like `node_modules` can be reused without reinstalling. + +The general-purpose worktree path is **not configurable** — it must live under `/.qwen/worktrees/` so the CLI can find it on restart and on stale-cleanup sweeps. (The unrelated `agents.arena.worktreeBaseDir` setting controls only [Agent Arena](./arena.md) worktrees, which use a separate path tree under `~/.qwen/arena/`.) + +## Footer and Status Line + +When a worktree is active, the Footer shows a dim indicator on its own row: + +``` +⎇ worktree-experiment-a (experiment-a) +``` + +If you use a [custom status line script](./status-line.md), it also receives a `worktree` object in the JSON payload piped to stdin: + +```json +{ + "worktree": { + "name": "experiment-a", + "path": "/path/to/repo/.qwen/worktrees/experiment-a", + "branch": "worktree-experiment-a", + "original_cwd": "/path/to/repo", + "original_branch": "main" + } +} +``` + +The payload field is present **only** when a worktree is active, so a `null`-check (`input.worktree?.name`) is enough. + +If your custom status line already renders worktree info, you can hide the built-in Footer row to avoid duplication — see [Settings](#settings) below. + +## Exit Dialog (Ctrl+C / Ctrl+D) + +Pressing the quit shortcut twice while a worktree is active opens the **Worktree Exit Dialog** instead of closing the CLI: + +``` +⎇ Active worktree: "experiment-a" (worktree-experiment-a) + + • 2 new commit(s) on worktree-experiment-a + • 3 uncommitted file(s) + Removing the worktree will discard everything above. + +What would you like to do? + ○ Keep worktree (exit without deleting) + ○ Remove worktree and branch (discards 2 commit(s), 3 file(s)) + ○ Cancel (stay in session) +``` + +The dialog inspects the worktree on open (`git status --porcelain` + `git rev-list ..HEAD`) and surfaces both counts so you know exactly what you'd be discarding. `ESC` cancels. + +If `git status` itself fails (e.g. corrupt index, worktree directory was removed under the CLI), the dialog shows a `⚠ Could not measure worktree state` warning and the counts may be unreliable — choose **Keep** or **Cancel** until you've diagnosed the underlying repo problem. + +## `--resume` Restore + +The active worktree binding is persisted to a sidecar file alongside your session transcript: + +``` +/.worktree.json +``` + +When you launch the CLI with `--resume ` (or pick the session from `/resume`), three things happen consistently across **interactive TUI**, **headless `-p`**, and **ACP/Zed** modes: + +1. The sidecar is loaded and the worktree directory is verified to still exist on disk. +2. If alive, the model receives a one-shot reminder on its very next prompt: + ``` + [Resumed] Active worktree: "" at (branch: ). Continue using this path for all file operations. + ``` +3. If the worktree directory was deleted between sessions, the stale sidecar is cleaned up automatically — no error, the resume just continues without worktree context. + +Each mode chooses its own injection mechanism, but the user-visible behavior is identical: + +| Mode | Mechanism | +| ----------------- | ------------------------------------------------------------------------------------------------------ | +| Interactive (TUI) | `INFO` history item + system-reminder prefix on the next user prompt. | +| Headless (`-p`) | `` prefix on the prompt + `worktree_restored` JSON system event in the output stream. | +| ACP (e.g. Zed) | Pending notice attached to the next `prompt()` call. | + +The model is **not** automatically `chdir`'d into the worktree — the reminder is what keeps it routing edits through the worktree path. + +## Sub-Agent Isolation + +The `agent` tool accepts an optional `isolation: "worktree"` parameter. When set, Qwen Code creates an ephemeral worktree at `/.qwen/worktrees/agent-<7hex>/` before the sub-agent starts, and: + +- **No changes** → the worktree is automatically removed when the agent finishes. +- **Has changes** → the worktree is preserved; its path and branch are appended to the agent's result, e.g. + ``` + …agent output… + [worktree preserved: /path/to/.qwen/worktrees/agent-3f2a1b9 (branch worktree-agent-3f2a1b9)] + ``` + Review the diff and merge or delete it manually. + +Two constraints: + +- `isolation: "worktree"` requires a `subagent_type` — forked sub-agents (no `subagent_type`) reuse the parent's full conversation context, so isolating them would split intent from working tree. +- Background agents (`run_in_background: true`) work fine with isolation; the cleanup runs when the agent reports completion. + +### Automatic Stale Cleanup + +Ephemeral agent worktrees that survived a crash or `--no-cleanup` shutdown are reaped on every CLI startup, with conservative fail-closed rules: + +| Guard | Behavior | +| -------------------------------------- | ---------------------------------------------- | +| Slug must match `agent-<7hex>` pattern | Named worktrees you created are never touched. | +| Directory `mtime` > 30 days | Newer entries are skipped. | +| Any uncommitted tracked change | Skip the entry (don't delete). | +| Any commit not reachable from a remote | Skip the entry (don't delete). | +| Any error reading git state | Skip the entry (don't delete). | + +Named user worktrees (`enter_worktree` slugs) are **never** auto-cleaned — you keep them around until you ask to remove them. + +## Safety Guards on `exit_worktree action="remove"` + +Three independent guards trigger before the directory and branch are deleted: + +1. **Session ownership** — each worktree carries a sidecar marker with the session ID that created it. A different session trying to remove it is refused with a clear error pointing at `git worktree remove` for the manual escape hatch. +2. **Dirty working tree** — uncommitted tracked or untracked changes block removal. Pass `discard_changes: true` to override. (Bypass requires explicit user confirmation — `action: "remove"` is never auto-approved in AUTO_EDIT mode.) +3. **Unmerged commits** — commits on `worktree-` that no other local branch or remote ref points at block removal unconditionally; there is no "discard commits" flag because losing committed work is rarely what users mean. Merge, push, or rename the branch elsewhere first. + +The same three guards apply to the `WorktreeExitDialog → Remove` button. + +## Settings + +Two settings shape the general-purpose worktree experience: + +| Key | Type | Default | Effect | +| --------------------------------- | ---------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ui.hideBuiltinWorktreeIndicator` | boolean | `false` | Hides the built-in `⎇ worktree-… (…)` Footer row. The `worktree` field is still delivered to custom status line scripts. Set to `true` only if your status line already renders the worktree — otherwise you lose all UI affordance. | +| `worktree.symlinkDirectories` | `string[]` | `undefined` | Directories under the main repo to symlink into every general-purpose worktree on creation. Paths are relative to the repo root; absolute paths and any entry containing `..` are rejected. Missing sources and existing destinations are silently skipped (no overwrite). | + +Example: + +```jsonc +// ~/.qwen/settings.json or /.qwen/settings.json +{ + "worktree": { + "symlinkDirectories": ["node_modules", ".turbo", "dist"], + }, +} +``` + +Applies to ALL worktree-creation paths: `--worktree` flag, `enter_worktree` tool, and `agent isolation: "worktree"`. + +Settings unrelated to general worktrees but worth knowing about: + +- `agents.arena.worktreeBaseDir` — controls **Agent Arena** worktree placement (default `~/.qwen/arena`). Does not affect general-purpose worktrees, which always live under `/.qwen/worktrees/`. + +There is no schema for `worktree.sparsePaths` yet — that's a roadmap item (see [Limitations](#limitations)). + +## Tool Reference + +### `enter_worktree` + +```json +{ "name": "experiment-a" } +``` + +| Field | Type | Required | Notes | +| ------ | ------ | -------- | ------------------------------------------------------------------------------------------ | +| `name` | string | no | Slug. Letters, digits, dot, underscore, hyphen; max 64 chars. Auto-generated when omitted. | + +Refuses to run when: + +- The CLI is not in a git repository. +- The current working directory is already inside `.qwen/worktrees/` (no nested worktrees). + +### `exit_worktree` + +```json +{ "name": "experiment-a", "action": "remove", "discard_changes": false } +``` + +| Field | Type | Required | Notes | +| ----------------- | ---------------------- | ------------------------------------- | ------------------------------------------------------------------ | +| `name` | string | yes | Must match the slug used in `enter_worktree`. | +| `action` | `"keep"` \| `"remove"` | yes | `keep` preserves dir + branch; `remove` deletes both. | +| `discard_changes` | boolean | only when `action="remove"` and dirty | Overrides the dirty-tree guard. Has no effect for `action="keep"`. | + +`action: "remove"` always prompts for confirmation, including under `AUTO_EDIT` approval mode — it is treated as a destructive shell operation, not an info-only tool. + +### `agent` — `isolation` parameter + +```json +{ + "subagent_type": "my-agent", + "description": "…", + "prompt": "…", + "isolation": "worktree" +} +``` + +| Field | Type | Required | Notes | +| ----------- | ------------ | -------- | ------------------------------------------------------------------------------------------------- | +| `isolation` | `"worktree"` | no | Runs the agent in a fresh `agent-<7hex>` worktree. Requires `subagent_type` to be set (no forks). | + +See [Sub-Agents](./sub-agents.md) for the rest of the agent tool reference. + +## CLI Reference + +### `--worktree [name | #N | url]` + +```bash +qwen --worktree # auto-generate slug +qwen --worktree my-feature # explicit slug +qwen --worktree=my-feature # = form +qwen --worktree=#123 # PR reference +qwen --worktree https://github.com/owner/repo/pull/123 # PR URL +``` + +| Input | Result | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Bare flag (no value) | Auto slug `--<6hex>`, branch `worktree-`, base = current branch. | +| Plain slug | Branch `worktree-`, base = current branch. Slug validation: letters/digits/dot/underscore/hyphen, max 64 chars. | +| `#N` or `/pull/N` | Slug `pr-`, branch `worktree-pr-`, base = `FETCH_HEAD` after `git fetch origin pull//head` (30s timeout). | + +`--worktree` cannot be combined with `--acp` / `--experimental-acp`. + +When `--worktree` is combined with `--resume `, the worktree wins: the resumed session's saved worktree (if any) is overridden and a stderr line + first-prompt reminder report the override. + +For interactive (TUI) and headless (`-p`) modes the worktree is automatically created and the session chdirs into it before the first turn. + +PR-fetch failure modes (exit code != 0, no worktree created): + +| Cause | Message excerpt | +| ----------------------------- | ---------------------------------------------------------- | +| Missing `origin` remote | `requires an "origin" remote that points at GitHub` | +| PR doesn't exist on origin | `Failed to fetch PR #: the PR does not exist on origin` | +| 30s network timeout | `Failed to fetch PR #: timed out after 30s` | +| PR number out of range / zero | `Invalid PR number` | + +## Limitations + +The following items are intentionally not implemented in the current phase: + +- **No sparse checkout.** Large monorepos check out the full tree. (`worktree.sparsePaths` is a roadmap item.) +- **No tmux integration.** The CLI does not spawn worktree sessions in new tmux windows. +- **Worktrees are separate "projects" for session storage.** Sessions started with `--worktree foo` are saved under that worktree's chats dir; to resume them later you must pass `--worktree foo` again. Sessions started without `--worktree` are saved under the main checkout and won't appear in the worktree's resume picker. +- **No cross-slug session override.** `qwen --resume --worktree second` where `` was created with `--worktree first` will fail to find the session — sessions and worktrees are tightly bound by `projectHash(cwd)`. To switch worktrees on an existing session you must exit, then re-launch with the new `--worktree` and a fresh prompt. A future architectural change (anchoring storage at the repo root instead of `cwd`) would lift this constraint. +- **Mid-session `enter_worktree` does NOT switch `process.cwd()` or `Config.targetDir`.** That tool uses the model-context-only convention (see [Sub-Agents](./sub-agents.md)). Only the startup `--worktree` flag actually switches the process working directory. +- **Relative paths in other arg fields are resolved BEFORE the worktree chdir.** Path-taking flags (`--mcp-config`, `--openai-logging-dir`, `--json-file`, `--input-file`, `--telemetry-outfile`, `--include-directories`) are normalized to absolute paths against the launch cwd when `--worktree` is set. Other path-shaped argv fields not in this list still resolve against the worktree cwd — use absolute paths to be safe. + +Track the roadmap in `docs/design/worktree.md`. + +## Troubleshooting + +**The Footer shows no worktree indicator even though I just created one.** +Check that `ui.hideBuiltinWorktreeIndicator` is not set to `true`. Also confirm the slug is non-empty in the tool's success message. + +**`--resume` does not restore my worktree.** +Check `/.worktree.json` exists. The CLI deletes the sidecar automatically when the worktree directory is gone, so a missing sidecar plus a missing directory is the normal "no worktree to restore" state — not a bug. Run with `--debug` and grep for `restoreWorktreeContext` to see the reason. + +**`exit_worktree` says "created by a different session".** +This is the session-ownership guard. Resume the original session and exit from there, or run the suggested `git worktree remove …` command manually. + +**Stale `agent-` worktrees keep piling up.** +The 30-day cutoff is conservative; sweep manually with `git worktree list && git worktree remove `, or wait — the next CLI startup after the 30-day mark will reap them as long as they are clean and pushed. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index baea941922e..cdc7f259845 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -170,6 +170,17 @@ export interface CliArgs { forkSession?: boolean | undefined; /** Internal: preserve the outer session ID when relaunching in a sandbox */ sandboxSessionId?: string | undefined; + /** + * Start the session inside a git worktree. Accepted forms: + * - bare `--worktree` (empty string from yargs) → auto-generated slug + * - `--worktree foo` / `--worktree=foo` → explicit slug + * - `--worktree=#123` / `--worktree https://github.com/o/r/pull/123` → PR ref + * + * Consumed by `setupStartupWorktree()` before `loadCliConfig()`. When set, + * the CLI chdirs into `/.qwen/worktrees//` and the entire + * session runs inside that worktree. + */ + worktree?: string | undefined; maxSessionTurns: number | undefined; coreTools: string[] | undefined; excludeTools: string[] | undefined; @@ -824,6 +835,14 @@ export async function parseArguments(): Promise { type: 'string', hidden: true, }) + .option('worktree', { + type: 'string', + description: + 'Start the session inside a git worktree at /.qwen/worktrees//. ' + + 'Pass a slug (`--worktree my-feature`), a PR reference (`--worktree=#123` or a full ' + + 'GitHub pull-request URL), or use bare `--worktree` to auto-generate a slug. ' + + 'On exit, the WorktreeExitDialog prompts to keep or remove the worktree.', + }) .option('max-session-turns', { type: 'number', description: 'Maximum number of session turns', @@ -1814,6 +1833,11 @@ export async function loadCliConfig( : undefined, } : undefined, + worktree: settings.worktree + ? { + symlinkDirectories: settings.worktree.symlinkDirectories, + } + : undefined, }; const config = new Config(configParams); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 46726450ae8..ce7b77395f1 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2272,6 +2272,41 @@ const SETTINGS_SCHEMA = { }, }, }, + + worktree: { + type: 'object', + label: 'Worktree', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Configuration for general-purpose git worktrees created by the ' + + 'CLI (the `enter_worktree` tool, the `agent isolation: "worktree"` ' + + 'parameter, and the startup `--worktree` flag). Does NOT affect ' + + 'Agent Arena worktrees — see `agents.arena.worktreeBaseDir` for those.', + showInDialog: false, + properties: { + symlinkDirectories: { + type: 'array', + label: 'Symlink Directories Into Worktrees', + category: 'Advanced', + requiresRestart: false, + default: undefined as string[] | undefined, + description: + 'Directories under the main repository to symlink into every ' + + 'general-purpose worktree on creation. Useful for sharing ' + + 'large opt-in dirs like `node_modules` so the model can run ' + + 'tests / builds inside the worktree without a fresh install. ' + + 'Paths must be relative to the repo root; absolute paths, ' + + 'anything containing `..`, and any path inside `.git` or ' + + '`.qwen` (the CLI-managed metadata tree, which contains ' + + 'the worktrees directory itself) are rejected. Missing ' + + 'source dirs and existing destination paths are silently ' + + 'skipped (no overwrite, no failure).', + showInDialog: false, + }, + }, + }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 94061489e9b..af40f32307c 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -21,7 +21,7 @@ import { import { render } from 'ink'; import dns from 'node:dns'; import os from 'node:os'; -import { basename } from 'node:path'; +import path, { basename } from 'node:path'; import v8 from 'node:v8'; import React from 'react'; import { validateAuthMethod } from './config/auth.js'; @@ -39,6 +39,12 @@ import { type InitializationResult, } from './core/initializer.js'; import { runNonInteractive } from './nonInteractiveCli.js'; +import { + setupStartupWorktree, + persistStartupWorktreeSidecar, + buildStartupWorktreeNotice, + type StartupWorktreeContext, +} from './startup/worktreeStartup.js'; import { runNonInteractiveStreamJson } from './nonInteractive/session.js'; import { AppContainer } from './ui/AppContainer.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; @@ -581,6 +587,86 @@ export async function main() { } } + // When --worktree is going to chdir us into a worktree below, resolve + // any relative-path argv fields to absolute paths now — BEFORE the + // chdir. Otherwise downstream `fs.existsSync('./mcp.json')` calls in + // `loadCliConfig` re-resolve against the worktree dir, where the file + // doesn't exist. Only touches values that look like paths (mcpConfig + // also accepts inline JSON — skip those). + // + // The list of fields below is hand-maintained. If you add a new + // CLI flag that takes a relative path, register it here too, + // otherwise --worktree silently breaks for that flag. + if (argv.worktree !== undefined) { + const launchCwdForPaths = process.cwd(); + const looksLikeInlineJson = (v: string): boolean => { + const t = v.trim(); + return t.startsWith('{') || t.startsWith('['); + }; + const resolveIfPath = (v: string | undefined): string | undefined => { + if (typeof v !== 'string' || v.length === 0) return v; + if (looksLikeInlineJson(v)) return v; + return path.resolve(launchCwdForPaths, v); + }; + argv.mcpConfig = resolveIfPath(argv.mcpConfig); + argv.openaiLoggingDir = resolveIfPath(argv.openaiLoggingDir); + argv.jsonFile = resolveIfPath(argv.jsonFile); + argv.inputFile = resolveIfPath(argv.inputFile); + argv.telemetryOutfile = resolveIfPath(argv.telemetryOutfile); + if (Array.isArray(argv.includeDirectories)) { + argv.includeDirectories = argv.includeDirectories.map((d) => + typeof d === 'string' && d.length > 0 + ? path.resolve(launchCwdForPaths, d) + : d, + ); + } + // `--json-schema` accepts either an inline schema or `@`. The + // `@`-prefixed form is read from disk inside `resolveJsonSchemaArg` + // (`packages/cli/src/config/config.ts`), AFTER chdir, so a relative + // value would resolve against the worktree — fix the prefix path + // here. + if (typeof argv.jsonSchema === 'string') { + const trimmedSchema = argv.jsonSchema.trim(); + if (trimmedSchema.startsWith('@')) { + const rel = trimmedSchema.slice(1); + if (rel.length > 0 && !path.isAbsolute(rel)) { + argv.jsonSchema = '@' + path.resolve(launchCwdForPaths, rel); + } + } + } + } + + // Phase D-1: process --worktree before the resume picker so the picker + // (which uses process.cwd() to scope its session search) finds sessions + // saved inside the target worktree. Creates the worktree directory on + // disk and chdirs into it; on failure we emit to stderr and exit before + // any expensive initialization runs. + // + // ACP mode is exempt: the ACP host (Zed, etc.) supplies its own per-session + // cwd, and the startup-level chdir would not propagate. Reject the + // combination with a clear error rather than silently dropping --worktree. + let startupWorktreeContext: StartupWorktreeContext | null = null; + if (argv.worktree !== undefined && (argv.acp || argv.experimentalAcp)) { + writeStderrLine( + '--worktree cannot be combined with --acp / --experimental-acp. ' + + 'Pass the worktree path as the cwd of the ACP loadSession / newSession ' + + 'request instead.', + ); + process.exit(1); + } + { + const startupRes = await setupStartupWorktree(argv.worktree, { + symlinkDirectories: settings.merged.worktree?.symlinkDirectories, + }); + if (startupRes !== null) { + if (!startupRes.ok) { + writeStderrLine(startupRes.error); + process.exit(1); + } + startupWorktreeContext = startupRes.context; + } + } + // Handle --resume without a session ID, or with a custom title, by showing // the session picker. Set the runtime output dir early so the picker can find // sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is @@ -653,6 +739,51 @@ export async function main() { ); profileCheckpoint('after_load_cli_config'); + // Phase D-1: persist the WorktreeSession sidecar so Phase C's restore + // machinery on a subsequent `--resume` picks the worktree back up, and + // capture any override of a previously-resumed session's worktree so + // we can emit a one-shot notice on the model's first prompt. + // + // The notice is set BEFORE the persist attempt and AGAIN inside the + // try block (so the override addendum can be appended on success). + // A persist failure must NOT silently drop the notice — the cwd is + // already switched, and the model needs to know which worktree it's + // operating in regardless of whether the sidecar landed. + if (startupWorktreeContext) { + config.setPendingStartupWorktreeNotice( + buildStartupWorktreeNotice(startupWorktreeContext), + ); + try { + const startupWorktreePersist = await persistStartupWorktreeSidecar( + config, + startupWorktreeContext, + ); + if (startupWorktreePersist.overrodeResumedWorktree) { + writeStderrLine( + `--worktree overrode the resumed session's previous worktree ` + + `"${startupWorktreePersist.overriddenSlug ?? '(unknown)'}". ` + + `That worktree directory was left intact on disk.`, + ); + } + // Refresh the notice with the override addendum (if any). When + // there is no override this is a no-op text-wise; on override it + // gives the model the "you overrode " hint. TUI + // and headless consume this via Config.consumePendingStartupWorktreeNotice(); + // ACP is excluded above (`--worktree` × `--acp` is mutually + // exclusive — see the mutex check earlier in this function). + config.setPendingStartupWorktreeNotice( + buildStartupWorktreeNotice( + startupWorktreeContext, + startupWorktreePersist, + ), + ); + } catch (error) { + debugLogger.warn( + `--worktree sidecar persist failed (non-fatal, notice preserved): ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + // Register cleanup for MCP clients as early as possible // This ensures MCP server subprocesses are properly terminated on exit registerCleanup(() => config.shutdown()); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 51d148a33d8..29f31d6e932 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -211,6 +211,12 @@ describe('runNonInteractive', () => { // restore worktree context. These tests don't exercise resume, so // return undefined to short-circuit the helper. getResumedSessionData: vi.fn().mockReturnValue(undefined), + // Phase D-1: nonInteractiveCli calls this on every prompt to pick + // up the one-shot startup-worktree notice (set by gemini.tsx + // when --worktree was passed). These tests don't exercise the + // --worktree flag, so return null to short-circuit injection + // and let the resume-restore branch run. + consumePendingStartupWorktreeNotice: vi.fn().mockReturnValue(null), } as unknown as Config; mockSettings = { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 3c1125a15d0..f017e2d9332 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -375,27 +375,42 @@ export async function runNonInteractive( initialPartList = [{ text: input }]; } - // Phase C: when --resume restored a session with an active worktree, - // prepend a system-reminder block to the user prompt so the model - // knows to keep using the worktree path. Stale sidecars (worktree - // dir deleted between sessions) are cleaned up inside the helper. - // TUI does this via historyManager.addItem(INFO); headless does it - // here because there is no UI history to write into. - if (config.getResumedSessionData()) { + // Inject a worktree context notice into the model's first prompt. + // Two sources: the `--worktree` startup flag (set by gemini.tsx + // before loadCliConfig) takes precedence over the Phase C resume + // restore. TUI does this via historyManager.addItem(INFO); here in + // headless we prepend a `` block since there is + // no UI history to write into. + const withReminder = ( + existing: PartListUnion, + text: string, + ): PartListUnion => { + const reminderPart: Part = { + text: `\n${text}\n\n\n`, + }; + return Array.isArray(existing) + ? [reminderPart, ...existing] + : [reminderPart, existing]; + }; + + const startupNotice = config.consumePendingStartupWorktreeNotice(); + if (startupNotice) { + initialPartList = withReminder(initialPartList, startupNotice); + adapter.emitSystemMessage('worktree_started', { + notice: startupNotice, + }); + } else if (config.getResumedSessionData()) { try { const sessionPath = config .getSessionService() .getWorktreeSessionPath(sessionId); const restored = await restoreWorktreeContext(sessionPath); if (restored.contextMessage) { - const reminderPart: Part = { - text: `\n${restored.contextMessage}\n\n\n`, - }; - const partsArr = Array.isArray(initialPartList) - ? initialPartList - : [initialPartList]; - initialPartList = [reminderPart, ...partsArr]; - // Also surface the notice in the JSON stream so SDK consumers + initialPartList = withReminder( + initialPartList, + restored.contextMessage, + ); + // Surface the notice in the JSON stream so SDK consumers // can react to it (logging, UI hints, etc.). adapter.emitSystemMessage('worktree_restored', { slug: restored.session?.slug, diff --git a/packages/cli/src/startup/worktreeStartup.test.ts b/packages/cli/src/startup/worktreeStartup.test.ts new file mode 100644 index 00000000000..a8ff7074ae3 --- /dev/null +++ b/packages/cli/src/startup/worktreeStartup.test.ts @@ -0,0 +1,409 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + setupStartupWorktree, + buildStartupWorktreeNotice, +} from './worktreeStartup.js'; + +const exec = promisify(execFile); + +async function makeTempRepo(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-startup-test-')); + // macOS resolves /var → /private/var; pwd -P is the cheapest way to + // normalise. Use realpath so subsequent string comparisons against + // process.cwd() match exactly. + const resolved = await fs.realpath(dir); + await exec('git', ['init', '-q', '-b', 'main'], { cwd: resolved }); + await exec('git', ['config', 'user.email', 't@e.com'], { cwd: resolved }); + await exec('git', ['config', 'user.name', 't'], { cwd: resolved }); + await exec('git', ['config', 'commit.gpgsign', 'false'], { cwd: resolved }); + // Disable autocrlf so file contents committed and read back via the + // test compare byte-for-byte on Windows runners (where the default + // `core.autocrlf=true` checks files out with `\r\n`, breaking + // assertions like `expect(content).toBe('foo\n')`). + await exec('git', ['config', 'core.autocrlf', 'false'], { cwd: resolved }); + await exec('git', ['config', 'core.eol', 'lf'], { cwd: resolved }); + await fs.writeFile(path.join(resolved, 'README.md'), 'hello\n'); + await exec('git', ['add', 'README.md'], { cwd: resolved }); + await exec('git', ['commit', '-q', '-m', 'initial', '--no-verify'], { + cwd: resolved, + }); + return resolved; +} + +describe('setupStartupWorktree', () => { + // Real git operations + fetch through a local bare remote can take + // 10–15s on slower runners; bump the per-test ceiling so the PR-ref + // happy-path test doesn't flake. + vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 }); + + let prevCwd: string; + let tempRepo: string | null = null; + + beforeEach(() => { + prevCwd = process.cwd(); + }); + + afterEach(async () => { + // Restore cwd before cleanup so the test process can rm -rf the temp dir. + process.chdir(prevCwd); + if (tempRepo) { + await fs.rm(tempRepo, { recursive: true, force: true }); + tempRepo = null; + } + }); + + it('returns null when --worktree was not passed', async () => { + const res = await setupStartupWorktree(undefined); + expect(res).toBeNull(); + }); + + it('rejects when the launch cwd is not a git repo', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-nongit-')); + tempRepo = dir; + process.chdir(await fs.realpath(dir)); + + const res = await setupStartupWorktree('foo'); + expect(res).not.toBeNull(); + expect(res!.ok).toBe(false); + if (!res!.ok) { + expect(res!.error).toMatch(/not a git repository/i); + } + }); + + it('creates a worktree with an auto-generated slug for bare --worktree', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + const res = await setupStartupWorktree(''); + expect(res).not.toBeNull(); + expect(res!.ok).toBe(true); + if (res!.ok) { + // adj-noun-XXXXXX pattern from GitWorktreeService.generateAutoSlug + // (3 random bytes → 6 hex chars). + expect(res!.context.slug).toMatch(/^[a-z]+-[a-z]+-[0-9a-f]{6}$/); + expect(res!.context.branch).toBe(`worktree-${res!.context.slug}`); + expect(res!.context.worktreePath).toContain( + path.join('.qwen', 'worktrees', res!.context.slug), + ); + expect(res!.context.repoRoot).toBe(tempRepo); + expect(res!.context.originalBranch).toBe('main'); + // 40-char SHA + expect(res!.context.originalHeadCommit).toMatch(/^[0-9a-f]{40}$/); + expect(res!.context.isPullRequest).toBe(false); + + // process.cwd() was switched into the worktree. + expect(process.cwd()).toBe(res!.context.worktreePath); + + // The worktree directory exists on disk and is a real dir. + const stat = await fs.stat(res!.context.worktreePath); + expect(stat.isDirectory()).toBe(true); + } + }); + + it('creates a worktree with an explicit slug', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + const res = await setupStartupWorktree('my-feature'); + expect(res).not.toBeNull(); + expect(res!.ok).toBe(true); + if (res!.ok) { + expect(res!.context.slug).toBe('my-feature'); + expect(res!.context.branch).toBe('worktree-my-feature'); + expect(res!.context.worktreePath).toBe( + path.join(tempRepo, '.qwen', 'worktrees', 'my-feature'), + ); + } + }); + + it('rejects invalid slug characters before any git operation', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + const res = await setupStartupWorktree('../escape'); + expect(res).not.toBeNull(); + expect(res!.ok).toBe(false); + if (!res!.ok) { + expect(res!.error.toLowerCase()).toMatch( + /letters|hyphens|invalid|may only/, + ); + } + + // No worktree directory was created. + const exists = await fs + .stat(path.join(tempRepo, '.qwen', 'worktrees')) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + + // cwd was not changed. + expect(process.cwd()).toBe(tempRepo); + }); + + it('rejects #N PR references when origin remote is missing', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + // Temp repo has no `origin` remote — fetch should fail-close with a + // clear hint about adding origin. + const res = await setupStartupWorktree('#123'); + expect(res).not.toBeNull(); + expect(res!.ok).toBe(false); + if (!res!.ok) { + expect(res!.error).toContain('#123'); + expect(res!.error.toLowerCase()).toContain('origin'); + } + + // No worktree directory was created — fail-close means no side effect. + const exists = await fs + .stat(path.join(tempRepo, '.qwen', 'worktrees')) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); + + it('rejects full GitHub PR URLs when origin remote is missing', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + const res = await setupStartupWorktree( + 'https://github.com/QwenLM/qwen-code/pull/4174', + ); + expect(res).not.toBeNull(); + expect(res!.ok).toBe(false); + if (!res!.ok) { + expect(res!.error).toContain('#4174'); + expect(res!.error.toLowerCase()).toContain('origin'); + } + }); + + it('creates a pr- worktree from FETCH_HEAD when fetch succeeds (local fake remote)', async () => { + // Set up a fake "origin" repo that exposes refs/pull//head — git + // fetch only cares that the refspec exists on the remote, not that + // the remote is github.com. update-ref lets us materialise the ref + // locally without an actual GitHub round-trip. + const upstream = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-wt-pr-upstream-'), + ); + const upstreamResolved = await fs.realpath(upstream); + await exec('git', ['init', '-q', '--bare', '-b', 'main'], { + cwd: upstreamResolved, + }); + + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + await exec('git', ['remote', 'add', 'origin', upstreamResolved], { + cwd: tempRepo, + }); + await exec('git', ['push', '-q', 'origin', 'main'], { cwd: tempRepo }); + + // Author a "PR commit" on a feature branch in the local repo, push + // it to the upstream as refs/pull/42/head. + await exec('git', ['checkout', '-q', '-b', 'pr-source'], { cwd: tempRepo }); + await fs.writeFile(path.join(tempRepo, 'pr-file.txt'), 'from PR 42\n'); + await exec('git', ['add', 'pr-file.txt'], { cwd: tempRepo }); + await exec('git', ['commit', '-q', '-m', 'PR 42 commit', '--no-verify'], { + cwd: tempRepo, + }); + await exec('git', ['push', '-q', 'origin', 'HEAD:refs/pull/42/head'], { + cwd: tempRepo, + }); + await exec('git', ['checkout', '-q', 'main'], { cwd: tempRepo }); + // Drop the local pr-source branch so the worktree branch isn't + // confused with it. + await exec('git', ['branch', '-q', '-D', 'pr-source'], { cwd: tempRepo }); + + try { + const res = await setupStartupWorktree('#42'); + expect(res).not.toBeNull(); + expect(res!.ok).toBe(true); + if (res!.ok) { + expect(res!.context.slug).toBe('pr-42'); + expect(res!.context.branch).toBe('worktree-pr-42'); + expect(res!.context.isPullRequest).toBe(true); + expect(res!.context.worktreePath).toBe( + path.join(tempRepo, '.qwen', 'worktrees', 'pr-42'), + ); + + // The PR file lives inside the worktree (proving FETCH_HEAD was + // the base, not main). + const prFile = await fs.readFile( + path.join(res!.context.worktreePath, 'pr-file.txt'), + 'utf8', + ); + expect(prFile).toBe('from PR 42\n'); + + // Phase D-3 round 4: `originalHeadCommit` for PR worktrees must + // be the resolved FETCH_HEAD SHA (the PR tip), NOT the parent + // repo's HEAD. `WorktreeExitDialog`'s `rev-list ..HEAD` + // later relies on this to count only the user's own commits in + // the worktree, not the entire PR's history. + expect(res!.context.originalHeadCommit).toMatch(/^[0-9a-f]{40}$/); + // Resolve the PR ref directly and compare: must match. + const expectedSha = ( + await exec('git', ['rev-parse', 'refs/pull/42/head'], { + cwd: upstreamResolved, + }) + ).stdout.trim(); + expect(res!.context.originalHeadCommit).toBe(expectedSha); + // Sanity: must NOT equal the parent repo's main HEAD. + const parentHead = ( + await exec('git', ['rev-parse', 'HEAD'], { cwd: tempRepo }) + ).stdout.trim(); + expect(res!.context.originalHeadCommit).not.toBe(parentHead); + } + } finally { + // Restore cwd before rm so the upstream cleanup doesn't hit EBUSY. + process.chdir(prevCwd); + await fs.rm(upstreamResolved, { recursive: true, force: true }); + } + }); + + it('re-attaches to an existing worktree instead of erroring (Phase 6 G1 fix)', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + // First call creates the worktree. + const first = await setupStartupWorktree('reattach-test'); + expect(first).not.toBeNull(); + expect(first!.ok).toBe(true); + if (!first!.ok) return; + expect(first!.context.wasReattached).toBe(false); + + // Restore cwd so the second call starts from launch cwd, mirroring + // the real `qwen --resume --worktree foo` invocation flow. + process.chdir(tempRepo); + + // Second call with the same slug now re-attaches, doesn't create. + const second = await setupStartupWorktree('reattach-test'); + expect(second).not.toBeNull(); + expect(second!.ok).toBe(true); + if (!second!.ok) return; + expect(second!.context.wasReattached).toBe(true); + expect(second!.context.slug).toBe('reattach-test'); + expect(second!.context.branch).toBe('worktree-reattach-test'); + expect(second!.context.worktreePath).toBe(first!.context.worktreePath); + expect(process.cwd()).toBe(first!.context.worktreePath); + }); + + it('refuses to re-attach when an existing dir occupies the slot on a different branch', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + // Manually create a directory at the would-be worktree path that + // is NOT a git worktree (just a plain dir with a file in it). + const slotPath = path.join( + tempRepo, + '.qwen', + 'worktrees', + 'plain-dir-conflict', + ); + await fs.mkdir(slotPath, { recursive: true }); + await fs.writeFile(path.join(slotPath, 'unexpected-content.txt'), 'oops'); + + // setupStartupWorktree should NOT silently re-attach (the dir is + // not a registered worktree). It also should NOT error — instead, + // it falls through to createUserWorktree which fails with the + // "already exists" branch. + const res = await setupStartupWorktree('plain-dir-conflict'); + expect(res).not.toBeNull(); + expect(res!.ok).toBe(false); + if (!res!.ok) { + // Either the re-attach branch error or createUserWorktree's + // "already exists" message is acceptable — both prevent clobbering. + expect(res!.error.toLowerCase()).toMatch( + /already exists|registered git worktree|expected/, + ); + } + + // Unexpected file survived. + const survived = await fs.readFile( + path.join(slotPath, 'unexpected-content.txt'), + 'utf8', + ); + expect(survived).toBe('oops'); + }); + + it('refuses nested worktree creation from inside .qwen/worktrees/', async () => { + tempRepo = await makeTempRepo(); + // Pre-create a fake worktree path and chdir into it. We don't need a + // real git worktree — the guard fires on path shape, not git state. + const nestedPath = path.join(tempRepo, '.qwen', 'worktrees', 'outer'); + await fs.mkdir(nestedPath, { recursive: true }); + process.chdir(nestedPath); + + const res = await setupStartupWorktree('inner'); + expect(res).not.toBeNull(); + expect(res!.ok).toBe(false); + if (!res!.ok) { + expect(res!.error.toLowerCase()).toMatch( + /nested|inside another worktree/, + ); + } + }); +}); + +describe('buildStartupWorktreeNotice', () => { + // Only the four fields the function actually consumes — the `Pick<>` + // signature lets us keep the fixture minimal so adding new + // StartupWorktreeContext fields doesn't churn this file. + const baseContext = { + worktreePath: '/repo/.qwen/worktrees/foo', + slug: 'foo', + branch: 'worktree-foo', + wasReattached: false, + }; + + it('produces a single line for the no-override created case', () => { + const notice = buildStartupWorktreeNotice(baseContext); + expect(notice).toContain('[Startup]'); + expect(notice).toContain('Active worktree'); + expect(notice).toContain('"foo"'); + expect(notice).toContain('/repo/.qwen/worktrees/foo'); + expect(notice).toContain('worktree-foo'); + expect(notice).not.toContain('Re-attached'); + expect(notice).not.toContain('overrode'); + }); + + it('uses "Re-attached" verb when wasReattached is true', () => { + const notice = buildStartupWorktreeNotice({ + ...baseContext, + wasReattached: true, + }); + expect(notice).toContain('[Startup]'); + expect(notice).toContain('Re-attached to worktree'); + expect(notice).not.toContain('Active worktree'); + }); + + it('appends an override hint when a previous worktree was overridden', () => { + const notice = buildStartupWorktreeNotice(baseContext, { + overrodeResumedWorktree: true, + overriddenSlug: 'old-slug', + sidecarPath: '/anywhere/sidecar.json', + }); + expect(notice).toContain('[Startup]'); + expect(notice).toContain('overrode'); + expect(notice).toContain('"old-slug"'); + expect(notice).toContain('qwen --worktree old-slug'); + }); + + it('does NOT append the override hint when overrodeResumedWorktree is false', () => { + const notice = buildStartupWorktreeNotice(baseContext, { + overrodeResumedWorktree: false, + sidecarPath: '/anywhere/sidecar.json', + }); + expect(notice).not.toContain('overrode'); + }); +}); diff --git a/packages/cli/src/startup/worktreeStartup.ts b/packages/cli/src/startup/worktreeStartup.ts new file mode 100644 index 00000000000..6ac5d0fd9ae --- /dev/null +++ b/packages/cli/src/startup/worktreeStartup.ts @@ -0,0 +1,470 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Startup-time worktree setup for the `--worktree` CLI flag (Phase D-1). + * + * Runs after argv parsing and before `loadCliConfig()` / `Config` construction + * so the resulting `process.cwd()` change feeds directly into the Config's + * `targetDir`. Three entry forms are supported (see {@link setupStartupWorktree}): + * + * - Empty string (bare `--worktree`) → auto-generated `{adj}-{noun}-{6hex}` slug + * - Plain slug (`--worktree my-feature`) → that exact slug + * - PR reference (`--worktree=#123`, `--worktree https://github.com/o/r/pull/123`) + * → slug `pr-`, fetched via `git fetch origin pull//head` and based + * off `FETCH_HEAD` (Phase D-3). + * + * Sidecar writing and `--resume` override accounting are NOT handled here — + * those need a constructed `Config` and live in {@link persistStartupWorktreeSidecar}. + */ + +import * as path from 'node:path'; +import { + createDebugLogger, + GitWorktreeService, + readWorktreeSession, + worktreeBranchForSlug, + writeWorktreeSession, + writeWorktreeSessionMarker, +} from '@qwen-code/qwen-code-core'; +import type { Config, WorktreeSession } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('WORKTREE_STARTUP'); + +/** + * `git rev-parse --abbrev-ref HEAD` returns this literal when the + * launch cwd has a detached HEAD checked out. Two related uses: + * + * 1. As an INPUT filter when normalizing `getCurrentBranch` output: + * we treat `'HEAD'` as "no real branch" and collapse to `undefined` + * so detached-state propagates uniformly through the slug/baseRef + * pipeline. + * 2. As the FALLBACK metadata string written to the sidecar's + * `originalBranch` field when the launch state was detached + * (no branch name to record). + */ +const DETACHED_HEAD = 'HEAD'; + +/** + * Resolved metadata for a startup worktree. Returned to the caller so the + * sidecar write (which needs `Config`) can happen after `loadCliConfig`. + */ +export interface StartupWorktreeContext { + /** Resolved absolute worktree path (where `process.cwd()` now points). */ + worktreePath: string; + /** Slug, e.g. `my-feature` or `pr-123`. */ + slug: string; + /** Branch name, e.g. `worktree-my-feature` or `worktree-pr-123`. */ + branch: string; + /** Repo top level captured before chdir. */ + repoRoot: string; + /** Branch that was checked out at worktree-creation time. */ + originalBranch: string; + /** HEAD SHA captured at worktree-creation time (for WorktreeExitDialog). */ + originalHeadCommit: string; + /** True iff the input was a PR reference. */ + isPullRequest: boolean; + /** + * True when the worktree directory already existed at startup and we + * re-attached to it. PR fetch is skipped + * on re-attach since the ref was materialized previously, and + * commit-count semantics in `WorktreeExitDialog` will track only this + * session's new commits. + */ + wasReattached: boolean; +} + +export type SetupStartupWorktreeResult = + | { ok: true; context: StartupWorktreeContext } + | { ok: false; error: string }; + +/** + * Resolves slug, creates the worktree, switches `process.cwd()`, and returns + * the metadata needed for the post-`loadCliConfig` sidecar write. + * + * Returns `null` when `rawInput === undefined` (no `--worktree` flag passed + * at all). Returns `{ ok: false, error }` for validation / git failures so + * the caller can print to stderr and exit with a controlled non-zero status. + * + * The caller is responsible for chdir-ing back if a later step fails — this + * helper does not roll back the worktree directory on a downstream error, + * matching `EnterWorktreeTool`'s "the worktree is yours now" semantics. + */ +export interface SetupStartupWorktreeOptions { + /** + * Mirrors `worktree.symlinkDirectories` (Phase D-2). Forwarded to + * `createUserWorktree` so the new worktree gets the same opt-in + * symlinks as `enter_worktree` and agent isolation worktrees do. + */ + symlinkDirectories?: readonly string[]; +} + +export async function setupStartupWorktree( + rawInput: string | undefined, + options?: SetupStartupWorktreeOptions, +): Promise { + if (rawInput === undefined) return null; + + // yargs delivers bare `--worktree` as an empty string (mirrors --resume). + // We accept it and fall through to auto-slug below. + const trimmed = rawInput.trim(); + + // Probe service rooted at the launch cwd so we can locate the repo top + // level before the chdir; the chdir target lives under that top level. + const launchCwd = process.cwd(); + const probe = new GitWorktreeService(launchCwd); + + const gitCheck = await probe.checkGitAvailable(); + if (!gitCheck.available) { + return { + ok: false, + error: `--worktree: ${gitCheck.error ?? 'git is not available on PATH.'}`, + }; + } + + // Refuse nested creation: launching with --worktree from inside an existing + // worktree creates `/.qwen/worktrees//`, which is rarely + // what the user wants and corrupts ownership tracking. + if (/[\\/]\.qwen[\\/]worktrees[\\/]/.test(launchCwd)) { + return { + ok: false, + error: `--worktree: cannot start a new worktree from inside another worktree (cwd: ${launchCwd}). Run from the main checkout.`, + }; + } + + // `getRepoTopLevel()` returns null when cwd is not inside a git repo, + // so a single subprocess covers both the is-a-repo gate and the + // top-level resolution we need for the worktree path. + const rawRepoRoot = await probe.getRepoTopLevel(); + if (rawRepoRoot === null) { + return { + ok: false, + error: `--worktree: ${launchCwd} is not a git repository. Run \`git init\` first or relaunch from inside one.`, + }; + } + // git always emits POSIX-style paths (forward slashes) via + // `--show-toplevel`. Normalize to the platform-native separator + // before storing or comparing so the sidecar's `originalCwd` and + // downstream `startsWith` checks don't mix `/` and `\` on Windows. + const repoRoot = path.resolve(rawRepoRoot); + const service = + repoRoot === launchCwd ? probe : new GitWorktreeService(repoRoot); + + // Resolve slug. Branch on PR reference first so `#123` / URLs don't fall + // through to slug validation (which would reject `#`). For PR refs we + // DEFER the fetch until we've checked whether the worktree already + // exists on disk — re-attach skips the fetch since the ref was + // materialized on the first run. + const prNumber = GitWorktreeService.parsePRReference(trimmed); + const isPullRequest = prNumber !== null; + let slug: string; + if (prNumber !== null) { + slug = `pr-${prNumber}`; + } else if (trimmed.length === 0) { + slug = GitWorktreeService.generateAutoSlug(); + } else { + const validation = GitWorktreeService.validateUserWorktreeSlug(trimmed); + if (validation) { + return { ok: false, error: `--worktree: ${validation}` }; + } + slug = trimmed; + } + + // Capture the launch-time branch and HEAD. These feed the WorktreeSession + // sidecar's `originalBranch` / `originalHeadCommit` fields when we go + // through the CREATE path; on re-attach the HEAD baseline is re-captured + // from inside the worktree itself (see the re-attach branch below) so + // `WorktreeExitDialog`'s `rev-list ..HEAD` counts + // only this session's new commits — not every commit the kept worktree + // accumulated across prior sessions. + // + // The two probes are independent — run in parallel to shave one + // subprocess off the critical path. Each is individually try-wrapped + // so a failure in one (unborn HEAD, partial init) doesn't poison + // the other. Detached-HEAD normalization via DETACHED_HEAD const. + const [originalBranchRaw, originalHeadCommit] = await Promise.all([ + service.getCurrentBranch().catch(() => undefined), + service.getCurrentCommitHash().catch(() => ''), + ]); + const originalBranch = + originalBranchRaw && originalBranchRaw !== DETACHED_HEAD + ? originalBranchRaw + : undefined; + + // Re-attach to an existing worktree instead of erroring out. Common + // case: user did `qwen --worktree foo` previously, exited with Keep, + // and now runs `qwen --resume --worktree foo` to continue. The + // directory + branch are already on disk; we just chdir into them. + // + // `getRegisteredWorktreeBranch` returns the worktree's HEAD commit + // alongside the branch (single rev-parse). Using THAT as + // `originalHeadCommit` instead of the launch-cwd capture is critical: + // `WorktreeExitDialog` later runs `rev-list ..HEAD` inside the + // worktree, so the launch-cwd HEAD would make it count every commit + // accumulated in the worktree across all prior sessions as "new work + // this session". + const expectedWorktreePath = service.getUserWorktreePath(slug); + const expectedBranch = worktreeBranchForSlug(slug); + let registered: { branch: string; headCommit: string } | null = null; + try { + registered = + await service.getRegisteredWorktreeBranch(expectedWorktreePath); + } catch { + registered = null; + } + if (registered !== null) { + if (registered.branch !== expectedBranch) { + // SOMETHING ELSE is occupying the path on a different branch — + // refuse to clobber it. + return { + ok: false, + error: + `--worktree: ${expectedWorktreePath} is already a git worktree, but its branch ` + + `is ${registered.branch} (expected ${expectedBranch}). Refusing to re-attach. ` + + `Resolve the conflict manually (e.g. \`git worktree remove ${expectedWorktreePath}\`).`, + }; + } + const worktreePath = path.resolve(expectedWorktreePath); + try { + process.chdir(worktreePath); + } catch (error) { + return { + ok: false, + error: `--worktree: failed to chdir into ${worktreePath} (${error instanceof Error ? error.message : String(error)}).`, + }; + } + debugLogger.debug( + `setupStartupWorktree: re-attached to existing worktree at ${worktreePath} (branch=${registered.branch})`, + ); + return { + ok: true, + context: { + worktreePath, + slug, + branch: registered.branch, + repoRoot, + originalBranch: originalBranch ?? DETACHED_HEAD, + originalHeadCommit: registered.headCommit, + isPullRequest, + wasReattached: true, + }, + }; + } + + // Phase D-3: fetch the PR ref BEFORE creating the worktree, so the + // base ref (FETCH_HEAD) is available to `git worktree add`. Skipped + // on re-attach above. Fail-close: any fetch error stops startup before + // we create disk state. + // + // Lock FETCH_HEAD to an immutable SHA *immediately* after the fetch: + // - closes a TOCTOU window in which a concurrent `git fetch` from + // any other process sharing this repo would overwrite FETCH_HEAD + // before `git worktree add` reads it, branching the new worktree + // off an unrelated commit; + // - lets us pass that same SHA back as `originalHeadCommit`, so + // `WorktreeExitDialog`'s `rev-list ..HEAD` later inside the + // worktree counts only the user's own new commits — not the + // entire fetched PR's history. + let pullRequestHeadSha: string | null = null; + if (prNumber !== null) { + const fetchRes = await service.fetchPullRequestRef(prNumber); + if (!fetchRes.success) { + return { ok: false, error: `--worktree: ${fetchRes.error}` }; + } + pullRequestHeadSha = await service.resolveRef('FETCH_HEAD'); + if (pullRequestHeadSha === null) { + return { + ok: false, + error: `--worktree: fetched PR #${prNumber} but FETCH_HEAD did not resolve to a commit SHA. Refusing to proceed (the worktree would otherwise branch off an unknown commit).`, + }; + } + } + + // For PR worktrees the base ref is the SHA we just locked in (NOT the + // literal `FETCH_HEAD`, which is mutable); for regular slugs we anchor + // at the parent session's currently checked-out branch. + const baseRef = isPullRequest ? pullRequestHeadSha! : originalBranch; + const result = await service.createUserWorktree(slug, baseRef, { + symlinkDirectories: options?.symlinkDirectories, + }); + if (!result.success || !result.worktree) { + return { + ok: false, + error: `--worktree: ${result.error ?? 'failed to create worktree.'}`, + }; + } + + // Switch the process working directory so loadCliConfig() picks up the + // worktree as targetDir, and subsequent shell / file operations land + // inside it. Mirror the convention used elsewhere in the codebase by + // working with the resolved absolute path. + const worktreePath = path.resolve(result.worktree.path); + try { + process.chdir(worktreePath); + } catch (error) { + return { + ok: false, + error: `--worktree: created worktree at ${worktreePath} but failed to chdir into it (${error instanceof Error ? error.message : String(error)}). Run \`cd ${worktreePath}\` manually.`, + }; + } + + return { + ok: true, + context: { + worktreePath, + slug, + branch: result.worktree.branch, + repoRoot, + originalBranch: originalBranch ?? DETACHED_HEAD, + // For PR worktrees, the worktree's HEAD starts at the fetched PR + // tip — not at the parent repo's HEAD. Use the SHA we locked in + // post-fetch so the exit-dialog rev-list counts only the user's + // new commits, not the entire PR history. + originalHeadCommit: isPullRequest + ? pullRequestHeadSha! + : originalHeadCommit, + isPullRequest, + wasReattached: false, + }, + }; +} + +/** + * Result of the post-`loadCliConfig` sidecar persist step. Callers use the + * boolean fields to decide whether to surface an INFO line in TUI / a + * `` in headless / a `pendingWorktreeNotice` in ACP. + */ +export interface PersistStartupWorktreeResult { + /** True when a pre-existing sidecar was found and overridden. */ + overrodeResumedWorktree: boolean; + /** + * Slug of the worktree that was overridden, when {@link overrodeResumedWorktree} + * is true. Used in the INFO message so users can re-attach to it if they + * launched with `--worktree` by mistake. + */ + overriddenSlug?: string; + /** Path to the sidecar file just written. */ + sidecarPath: string; +} + +/** + * Writes the `WorktreeSession` sidecar that Phase C's `--resume` restore + * machinery consumes, and tags the worktree directory with the current + * session ID so cross-session `exit_worktree action="remove"` is refused. + * + * Handles the `--worktree` × `--resume` precedence: when a sidecar already + * exists (the user resumed a session that previously had a different + * worktree), the new context wins and the previous slug is reported back + * so callers can show an INFO line. + */ +export async function persistStartupWorktreeSidecar( + config: Config, + context: StartupWorktreeContext, +): Promise { + const sessionId = config.getSessionId(); + const sidecarPath = config + .getSessionService() + .getWorktreeSessionPath(sessionId); + + // Read whatever sidecar exists before we clobber it, so we can detect + // and report an override. A read failure (corrupt JSON, permission) + // collapses to "no previous worktree" — the new sidecar still wins. + // Log the failure with the sidecar path so an operator can recover the + // previous slug from a backup if they care; silent loss would make + // "where did my previous worktree binding go?" undebuggable. + let overrodeResumedWorktree = false; + let overriddenSlug: string | undefined; + let previous: WorktreeSession | null = null; + try { + previous = await readWorktreeSession(sidecarPath); + } catch (error) { + debugLogger.warn( + `persistStartupWorktreeSidecar: failed to read existing sidecar at ${sidecarPath} — treating as "no previous worktree" and proceeding: ${error}`, + ); + previous = null; + } + if (previous && previous.slug !== context.slug) { + overrodeResumedWorktree = true; + overriddenSlug = previous.slug; + } + + // Best-effort marker write — same policy as EnterWorktreeTool: a failure + // here does not abort the session, the worktree is usable, ownership + // checks just treat the worktree as "owner unknown" for future + // exit_worktree calls. + // + // SKIP on re-attach: the marker was written by whichever session + // ORIGINALLY created this worktree. Overwriting with the current + // session id would let `exit_worktree action="remove"` succeed across + // sessions, bypassing Phase A's cross-session ownership guard. The + // existing marker stays so the original owner remains canonical; the + // current session can still operate INSIDE the worktree (file ops, + // commits) — ownership only governs the destructive remove. + if (!context.wasReattached) { + await writeWorktreeSessionMarker(context.worktreePath, sessionId).catch( + () => {}, + ); + } + + await writeWorktreeSession(sidecarPath, { + slug: context.slug, + worktreePath: context.worktreePath, + worktreeBranch: context.branch, + originalCwd: context.repoRoot, + originalBranch: context.originalBranch, + originalHeadCommit: context.originalHeadCommit, + }); + + // The previous worktree directory (if any) is intentionally left on + // disk — the user retains the ability to re-attach by launching again + // with `--worktree `. We only swap the sidecar's slug. + + return { overrodeResumedWorktree, overriddenSlug, sidecarPath }; +} + +/** + * Builds the one-shot context message that gets injected into the model on + * the first user prompt (TUI: INFO history item + reminder prefix; headless: + * `` prefix + JSON event; ACP currently exits before + * reaching this code path — see the `--worktree` × `--acp` mutex check + * in `gemini.tsx`). + * + * Mirrors `restoreWorktreeContext`'s contextMessage shape so resumed-with- + * worktree and started-with-worktree sessions read identically to the model. + * + * Differentiates the verb based on whether the worktree was just created + * or the CLI re-attached to a pre-existing one — same slug + branch but + * meaningfully different user intent. The override addendum (when + * `--worktree` clobbered a resumed session's prior worktree) is shown + * regardless of created/reattached state. + * + * Parameter type is `Pick` rather than the full + * context so test fixtures can construct minimal literals without + * tracking every internal field. Adding fields to {@link + * StartupWorktreeContext} should NOT force test-fixture churn here. + */ +export function buildStartupWorktreeNotice( + context: Pick< + StartupWorktreeContext, + 'slug' | 'worktreePath' | 'branch' | 'wasReattached' + >, + override?: PersistStartupWorktreeResult, +): string { + const verb = context.wasReattached + ? 'Re-attached to worktree' + : 'Active worktree'; + const base = + `[Startup] ${verb}: "${context.slug}" at ${context.worktreePath} ` + + `(branch: ${context.branch}). Continue using this path for all file operations.`; + if (override?.overrodeResumedWorktree && override.overriddenSlug) { + return ( + `${base}\n` + + `Note: --worktree overrode the resumed session's previous worktree "${override.overriddenSlug}". ` + + `That worktree directory was left intact; re-attach with \`qwen --worktree ${override.overriddenSlug}\` if needed.` + ); + } + return base; +} diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0ffa15c906e..769587f6e7d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -519,6 +519,21 @@ export const AppContainer = (props: AppContainerProps) => { // the profile captures the full MCP timeline without holding back // the user-facing TTI. + // Phase D-1: when launched with --worktree, gemini.tsx stashes a + // one-shot notice on Config. Consume it here so it surfaces in the + // transcript AND gets injected into the next user prompt. This + // wins over the Phase C resume-restore path below — startup beats + // resume on the same prompt. + const startupWorktreeNotice = + config.consumePendingStartupWorktreeNotice(); + if (startupWorktreeNotice) { + historyManager.addItem( + { type: MessageType.INFO, text: startupWorktreeNotice }, + Date.now(), + ); + pendingWorktreeNoticeRef.current = startupWorktreeNotice; + } + const resumedSessionData = config.getResumedSessionData(); if (resumedSessionData) { const historyItems = buildResumedHistoryItems( @@ -560,32 +575,39 @@ export const AppContainer = (props: AppContainerProps) => { // Restore worktree context (shared logic — headless and ACP use // the same helper). Stale sidecars get cleaned up; live ones // produce an INFO message the model sees on the next turn. - try { - const sessionPath = config - .getSessionService() - .getWorktreeSessionPath(config.getSessionId()); - const restored = await restoreWorktreeContext(sessionPath, (err) => { - // eslint-disable-next-line no-console - console.debug('worktree session restore warning:', err); - }); - if (restored.contextMessage) { - // UI: show the notice in the transcript so the user knows. - historyManager.addItem( - { type: MessageType.INFO, text: restored.contextMessage }, - Date.now(), + // Skipped when Phase D-1 already injected a --worktree startup + // notice above (startup wins over resume on the same prompt). + if (!startupWorktreeNotice) { + try { + const sessionPath = config + .getSessionService() + .getWorktreeSessionPath(config.getSessionId()); + const restored = await restoreWorktreeContext( + sessionPath, + (err) => { + // eslint-disable-next-line no-console + console.debug('worktree session restore warning:', err); + }, ); - // Model: queue the notice for one-shot injection into the - // next user prompt (consumed by handleFinalSubmit). The INFO - // history item alone is UI-only — the model never sees it, - // so without this it could resume editing the parent - // checkout despite the user seeing the worktree path. - pendingWorktreeNoticeRef.current = restored.contextMessage; + if (restored.contextMessage) { + // UI: show the notice in the transcript so the user knows. + historyManager.addItem( + { type: MessageType.INFO, text: restored.contextMessage }, + Date.now(), + ); + // Model: queue the notice for one-shot injection into the + // next user prompt (consumed by handleFinalSubmit). The INFO + // history item alone is UI-only — the model never sees it, + // so without this it could resume editing the parent + // checkout despite the user seeing the worktree path. + pendingWorktreeNoticeRef.current = restored.contextMessage; + } + } catch (error) { + // Best-effort: failures here only affect UI hint visibility, + // not the resumed conversation itself. + // eslint-disable-next-line no-console + console.debug('worktree session restore failed:', error); } - } catch (error) { - // Best-effort: failures here only affect UI hint visibility, - // not the resumed conversation itself. - // eslint-disable-next-line no-console - console.debug('worktree session restore failed:', error); } } })(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 733f8bab3e8..efa88cfd296 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -493,6 +493,27 @@ export interface SandboxConfig { * Settings shared across multi-agent collaboration features * (Arena, Team, Swarm). */ +/** + * General-purpose worktree settings (Phase D-2). Distinct from + * {@link AgentsCollabSettings.arena.worktreeBaseDir}, which only governs + * Arena multi-model worktrees. + */ +export interface WorktreeSettings { + /** + * Directories under the main repository to symlink into every + * general-purpose worktree on creation (the `enter_worktree` tool, + * `agent isolation: "worktree"`, and the `--worktree` startup flag). + * + * Paths must be relative to the repo root; absolute paths and any + * entry containing `..` are rejected by the service. Entries that + * resolve to git-internal paths (`.git`, `.qwen`) are also rejected + * — symlinking those would either break git inside the worktree or + * create a worktrees-inside-worktrees loop. Missing source dirs and + * pre-existing destinations are silently skipped. + */ + symlinkDirectories?: readonly string[]; +} + export interface AgentsCollabSettings { /** Display mode for multi-agent sessions ('in-process' | 'tmux' | 'iterm2') */ displayMode?: string; @@ -669,6 +690,8 @@ export interface ConfigParameters { modelProvidersConfig?: ModelProvidersConfig; /** Multi-agent collaboration settings (Arena, Team, Swarm) */ agents?: AgentsCollabSettings; + /** General-purpose worktree settings (Phase D-2). */ + worktree?: WorktreeSettings; /** Enable managed auto-memory background extraction and dream. Defaults to true. */ enableManagedAutoMemory?: boolean; /** Enable managed auto-dream consolidation separately from extraction. Defaults to true. */ @@ -774,6 +797,20 @@ const DEFAULT_BARE_CORE_TOOLS = [ export class Config { private sessionId: string; private sessionData?: ResumedSessionData; + /** + * One-shot notice produced by `setupStartupWorktree` (Phase D-1) when the + * CLI was launched with `--worktree`. The active entry point (TUI XOR + * headless) reads it via {@link consumePendingStartupWorktreeNotice} on + * the model's first prompt and skips Phase C's `restoreWorktreeContext` + * for that turn — startup wins over the resumed-session sidecar. ACP is + * gated out earlier in `gemini.tsx` (mutex with `--worktree`) so it + * never reaches this slot. + * + * @invariant At most one consumer per process. If a future entry path + * sets this slot without ever consuming, the string persists until + * process exit (which dies with the process — no leak). + */ + private pendingStartupWorktreeNotice: string | null = null; private debugLogger: DebugLogger; private toolRegistry!: ToolRegistry; /** @@ -911,6 +948,7 @@ export class Config { | null = null; private readonly arenaAgentClient: ArenaAgentClient | null; private readonly agentsSettings: AgentsCollabSettings; + private readonly worktreeSettings: WorktreeSettings; private readonly skipLoopDetection: boolean; private readonly skipStartupContext: boolean; private readonly bareMode: boolean; @@ -1112,6 +1150,7 @@ export class Config { this.eventEmitter = params.eventEmitter; this.arenaAgentClient = ArenaAgentClient.create(); this.agentsSettings = params.agents ?? {}; + this.worktreeSettings = params.worktree ?? {}; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); } @@ -2195,6 +2234,29 @@ export class Config { return this.targetDir; } + /** + * Stashes a one-shot context message that the next user prompt will + * inject into the model (see {@link pendingStartupWorktreeNotice}). Called + * from `gemini.tsx` right after `loadCliConfig` when `--worktree` produced + * a valid worktree. Pass `null` to clear (rarely needed). + */ + setPendingStartupWorktreeNotice(notice: string | null): void { + this.pendingStartupWorktreeNotice = notice; + } + + /** + * Reads and clears the pending startup-worktree notice. Returns `null` + * when nothing is stashed (the common case). Each entry point (TUI / + * headless / ACP) calls this on the model's first prompt; a non-null + * return means the entry point should NOT additionally call + * `restoreWorktreeContext()` for that prompt — startup overrides resume. + */ + consumePendingStartupWorktreeNotice(): string | null { + const v = this.pendingStartupWorktreeNotice; + this.pendingStartupWorktreeNotice = null; + return v; + } + getProjectRoot(): string { return this.targetDir; } @@ -2545,6 +2607,18 @@ export class Config { return this.agentsSettings; } + /** + * Convenience accessor for `worktree.symlinkDirectories` — returns an + * empty array when the setting is unset, so callers can pass the + * result directly into the GitWorktreeService loop without nullchecks. + * + * (No general `getWorktreeSettings()` getter yet — add one when a + * second field on `WorktreeSettings` justifies the broader API.) + */ + getWorktreeSymlinkDirectories(): readonly string[] { + return this.worktreeSettings.symlinkDirectories ?? []; + } + /** * Clean up Arena runtime. When `force` is true (e.g., /arena select --discard), * always removes worktrees regardless of preserveArtifacts. diff --git a/packages/core/src/services/gitWorktreeService.symlinks.integ.test.ts b/packages/core/src/services/gitWorktreeService.symlinks.integ.test.ts new file mode 100644 index 00000000000..b7caa3c938c --- /dev/null +++ b/packages/core/src/services/gitWorktreeService.symlinks.integ.test.ts @@ -0,0 +1,539 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Integration tests for `GitWorktreeService.symlinkConfiguredDirectories()` + * (Phase D-2). Uses real git invocations + real `fs.symlink` against a + * temp repo because the unit-test file mocks simple-git too heavily to + * exercise the actual symlink loop. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { GitWorktreeService } from './gitWorktreeService.js'; + +describe('GitWorktreeService.createUserWorktree() — symlinkDirectories', () => { + vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 }); + + let repoRoot: string; + + beforeEach(async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-symlinks-')); + // Resolve symlinks (macOS /var → /private/var) so path comparisons + // line up with what GitWorktreeService produces internally. + repoRoot = await fs.realpath(dir); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repoRoot }); + execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: repoRoot }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: repoRoot }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { + cwd: repoRoot, + }); + await fs.writeFile(path.join(repoRoot, 'README.md'), 'hi\n'); + execFileSync('git', ['add', '.'], { cwd: repoRoot }); + execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], { + cwd: repoRoot, + }); + }); + + afterEach(async () => { + await fs.rm(repoRoot, { recursive: true, force: true }); + }); + + it('symlinks a configured directory into the new worktree', async () => { + // Create a fake node_modules in the main repo so there's something + // to link. + const nm = path.join(repoRoot, 'node_modules'); + await fs.mkdir(nm); + await fs.writeFile(path.join(nm, 'marker'), 'real'); + + const service = new GitWorktreeService(repoRoot); + const result = await service.createUserWorktree('linked', 'main', { + symlinkDirectories: ['node_modules'], + }); + expect(result.success).toBe(true); + expect(result.worktree).toBeDefined(); + + const dest = path.join(result.worktree!.path, 'node_modules'); + const linkTarget = await fs.readlink(dest); + expect(linkTarget).toBe(nm); + + // Reading through the symlink resolves to the real file. + const marker = await fs.readFile(path.join(dest, 'marker'), 'utf8'); + expect(marker).toBe('real'); + }); + + it('silently skips a missing source directory', async () => { + const service = new GitWorktreeService(repoRoot); + const result = await service.createUserWorktree('missing-source', 'main', { + symlinkDirectories: ['does-not-exist'], + }); + // Worktree creation still succeeds. + expect(result.success).toBe(true); + expect(result.worktree).toBeDefined(); + + // Nothing was created at the would-be destination. + const dest = path.join(result.worktree!.path, 'does-not-exist'); + const exists = await fs + .lstat(dest) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); + + it('silently skips an existing destination (no overwrite)', async () => { + const nm = path.join(repoRoot, 'node_modules'); + await fs.mkdir(nm); + await fs.writeFile(path.join(nm, 'marker'), 'real'); + + const service = new GitWorktreeService(repoRoot); + // Pre-create the destination inside what will become the worktree. + // We can't pre-populate the worktree (it doesn't exist yet), so we + // exploit the fact that `git worktree add` creates the dir — we set + // the symlinkDirectories to the empty array first to create the + // worktree, then drop a file at the dest, then exercise the symlink + // path via a second call on a new slug. + // + // Actually, simpler: just test that on a second create attempt at + // the same slug, the create itself fails (because branch exists), + // so this case is only reachable in practice if a user pre-populates + // the worktree (e.g. via a custom checkout hook). Simulate by + // creating the worktree first with no symlinks, then dropping a + // marker file, then running the symlink loop manually via a fresh + // service instance pointed at a SECOND slug that pre-fills the dest. + + // Pre-create the worktree path so `createUserWorktree` errors out + // on its "already exists" guard — this is the wrong shape. Instead, + // create the worktree, hand-place a node_modules dir under it (the + // tool's pre-populated state), then call symlinkConfiguredDirectories + // directly. The method is private but accessible via prototype here + // because tests run in the same package. + const first = await service.createUserWorktree('preexisting', 'main', { + symlinkDirectories: [], + }); + expect(first.success).toBe(true); + const wt = first.worktree!.path; + await fs.mkdir(path.join(wt, 'node_modules')); + await fs.writeFile(path.join(wt, 'node_modules', 'preexisting'), 'wins'); + + // Invoke the private symlink loop directly. + // Probe the `private symlinkConfiguredDirectories` method directly. + // We can't intersect `GitWorktreeService` with a `public` version of + // the same name (TypeScript collapses class + redeclared-as-public + // intersection to `never`), so describe ONLY the method's shape and + // double-cast through `unknown` to bypass the private check at + // test-time. + type SymlinkProbe = { + symlinkConfiguredDirectories: ( + worktreePath: string, + configured: readonly string[], + ) => Promise; + }; + await (service as unknown as SymlinkProbe).symlinkConfiguredDirectories( + wt, + ['node_modules'], + ); + + // The preexisting file survived — no overwrite happened. + const marker = await fs.readFile( + path.join(wt, 'node_modules', 'preexisting'), + 'utf8', + ); + expect(marker).toBe('wins'); + + // And the directory at `wt/node_modules` is still the original dir, + // not a symlink to the main repo's node_modules. + const stat = await fs.lstat(path.join(wt, 'node_modules')); + expect(stat.isSymbolicLink()).toBe(false); + }); + + it('rejects absolute paths', async () => { + const service = new GitWorktreeService(repoRoot); + const result = await service.createUserWorktree('abs', 'main', { + symlinkDirectories: ['/etc'], + }); + expect(result.success).toBe(true); + // Nothing at /etc-named inside the worktree. + const dest = path.join(result.worktree!.path, 'etc'); + const exists = await fs + .lstat(dest) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); + + it('rejects paths that traverse outside the repo root', async () => { + // Put a sibling directory next to the repo so `../sibling` resolves to + // something real — proving the guard fires on traversal shape rather + // than on "source missing". + const siblingDir = path.join(path.dirname(repoRoot), 'qwen-wt-sibling'); + await fs.mkdir(siblingDir); + await fs.writeFile(path.join(siblingDir, 'marker'), 'outside'); + + const service = new GitWorktreeService(repoRoot); + const result = await service.createUserWorktree('traverse', 'main', { + symlinkDirectories: ['../qwen-wt-sibling'], + }); + + try { + expect(result.success).toBe(true); + const dest = path.join(result.worktree!.path, '..', 'qwen-wt-sibling'); + // No symlink was created inside the worktree directory. + const stat = await fs + .lstat(path.join(result.worktree!.path, 'qwen-wt-sibling')) + .catch(() => null); + expect(stat).toBeNull(); + // Sibling itself is untouched. + const marker = await fs.readFile(path.join(siblingDir, 'marker'), 'utf8'); + expect(marker).toBe('outside'); + // The variable `dest` is not used for assertion — silence unused warning. + void dest; + } finally { + await fs.rm(siblingDir, { recursive: true, force: true }); + } + }); + + it('rejects paths inside .git (security guard)', async () => { + // `.git` is git-internal; symlinking any of it into the worktree + // would shadow the worktree's gitlink file and silently break + // commits / status / diff. Verify the guard fires. + const service = new GitWorktreeService(repoRoot); + const result = await service.createUserWorktree('reject-git', 'main', { + symlinkDirectories: ['.git/hooks'], + }); + expect(result.success).toBe(true); + + const wt = result.worktree!.path; + // Nothing at /.git/hooks beyond what `git worktree add` + // itself populates — and certainly NOT a symlink that we wrote. + // The guard rejects pre-mkdir, so no `hooks` entry should exist + // (the worktree gets its own per-worktree .git file, not directory). + const wrote = await fs + .lstat(path.join(wt, '.git', 'hooks')) + .then((s) => s.isSymbolicLink()) + .catch(() => false); + expect(wrote).toBe(false); + }); + + it('rejects paths inside .qwen (security guard)', async () => { + // `.qwen` is CLI metadata: symlinking `.qwen/worktrees` would create + // a worktrees-inside-worktrees loop; symlinking `.qwen/projects` or + // `.qwen/tmp` would alias session metadata users have no legitimate + // reason to share across worktrees. Guard rejects the whole subtree. + await fs.mkdir(path.join(repoRoot, '.qwen'), { recursive: true }); + await fs.writeFile(path.join(repoRoot, '.qwen', 'projects'), 'data'); + + const service = new GitWorktreeService(repoRoot); + const result = await service.createUserWorktree('reject-qwen', 'main', { + symlinkDirectories: ['.qwen/projects'], + }); + expect(result.success).toBe(true); + + const wt = result.worktree!.path; + // No symlink at /.qwen/projects. + const wrote = await fs + .lstat(path.join(wt, '.qwen', 'projects')) + .then((s) => s.isSymbolicLink()) + .catch(() => false); + expect(wrote).toBe(false); + }); + + it('works when the repo path itself contains a symlink boundary (round-7 self-inflicted regression guard)', async () => { + // Round 7 introduced `realSource = await fs.realpath(sourceAbs)` and + // compared it against `repoRootAbs = path.resolve(sourceRepoPath)` — + // canonical vs lexical. On any system where the user's repo path + // contains a symlink component (macOS /tmp → /private/tmp, or a + // user-symlinked source tree on Linux/Windows), the prefixes diverge + // and `isWithinRoot` silently rejects EVERY configured entry. + // + // This guard provisions the same shape independently of the + // shared beforeEach (which realpaths `repoRoot` upfront, masking + // the bug). We point `GitWorktreeService` at a symlink path so + // `sourceRepoPath` differs from its canonical realpath. + + const realDirRaw = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-wt-realdir-'), + ); + const realDir = await fs.realpath(realDirRaw); + const linkParentRaw = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-wt-linkdir-'), + ); + const linkParent = await fs.realpath(linkParentRaw); + const repoViaSymlink = path.join(linkParent, 'repo-via-symlink'); + await fs.symlink(realDir, repoViaSymlink); + + try { + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: realDir }); + execFileSync('git', ['config', 'user.email', 't@e.com'], { + cwd: realDir, + }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: realDir }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { + cwd: realDir, + }); + await fs.writeFile(path.join(realDir, 'README.md'), 'hi\n'); + execFileSync('git', ['add', '.'], { cwd: realDir }); + execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], { + cwd: realDir, + }); + + // Create node_modules in the real dir so realpath resolves to a + // canonical path under realDir, NOT repoViaSymlink. + const nm = path.join(realDir, 'node_modules'); + await fs.mkdir(nm); + await fs.writeFile(path.join(nm, 'marker'), 'real'); + + // Service rooted at the SYMLINK path — that's the production shape + // since git rev-parse --show-toplevel returns the user-supplied + // path, not the canonical realpath. + const service = new GitWorktreeService(repoViaSymlink); + const result = await service.createUserWorktree('symlinkedrepo', 'main', { + symlinkDirectories: ['node_modules'], + }); + expect(result.success).toBe(true); + + // The configured entry must have been linked. Pre-fix: realSource + // = realDir/node_modules, repoRootAbs = repoViaSymlink (lexical) → + // isWithinRoot fails → entry silently rejected → dest absent. + const dest = path.join(result.worktree!.path, 'node_modules'); + const lst = await fs.lstat(dest).catch(() => null); + expect( + lst, + 'symlinkDirectories entry was silently rejected — canonical vs lexical isWithinRoot mismatch', + ).not.toBeNull(); + expect(lst!.isSymbolicLink()).toBe(true); + + // Reading through the link reaches the real file. + const marker = await fs.readFile(path.join(dest, 'marker'), 'utf8'); + expect(marker).toBe('real'); + } finally { + // Remove via the realpath, not the symlink, so rm-rf clears the + // backing directory cleanly. The dangling symlink in linkParent + // gets removed when we rm-rf linkParent. + await fs.rm(realDir, { recursive: true, force: true }); + await fs.rm(linkParent, { recursive: true, force: true }); + } + }); + + it('refuses sources whose realpath escapes the repo root or lands in .git/.qwen (committed-symlink bypass)', async () => { + // Round-7 security fix: the lexical `isWithinRoot(sourceAbs, …)` and + // `.git`/`.qwen` blocklist checks DON'T resolve symlinks, so a symlink + // committed into the source repo HEAD (or set up out-of-band by a + // malicious post-install script / repo tarball) can chain through to + // arbitrary targets. Two flavours covered here: + // + // 1. `escape-to-git` is an OUT-OF-BAND symlink pointing at .git. + // `fs.stat(/escape-to-git)` follows the symlink and + // succeeds against the .git directory; without the realpath + // guard, we'd happily create `/escape-to-git → + // /escape-to-git → /.git`, giving any tool inside + // the worktree read/write access to .git/hooks, .git/config, + // etc. + // + // 2. `escape-to-outside` is an OUT-OF-BAND symlink pointing at a + // sibling dir OUTSIDE the repo. Same bypass shape; targets + // whatever lives at the other end (e.g. /etc, ~/.aws, etc.). + // + // Both are intentionally set up out-of-band (no `git add`) so we + // don't rely on EEXIST-from-checkout masking the issue; the + // worktree's dest path is empty when the symlink loop runs, so + // without the realpath guard `fs.symlink` would succeed. + await fs.symlink('.git', path.join(repoRoot, 'escape-to-git')); + + const outsideDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-wt-outside-'), + ); + const outsideResolved = await fs.realpath(outsideDir); + await fs.writeFile(path.join(outsideResolved, 'secret'), 'should-not-leak'); + await fs.symlink(outsideResolved, path.join(repoRoot, 'escape-to-outside')); + + try { + const service = new GitWorktreeService(repoRoot); + const result = await service.createUserWorktree('bypass', 'main', { + symlinkDirectories: ['escape-to-git', 'escape-to-outside'], + }); + expect(result.success).toBe(true); + + const wt = result.worktree!.path; + + // Neither entry should have produced a symlink we wrote: the + // realpath check refuses .git-chain and out-of-repo targets. + for (const name of ['escape-to-git', 'escape-to-outside']) { + const dest = path.join(wt, name); + const exists = await fs + .lstat(dest) + .then(() => true) + .catch(() => false); + expect( + exists, + `realpath guard must refuse to create /${name} — committed symlink escape would chain through to a sensitive location`, + ).toBe(false); + } + + // Belt-and-suspenders: the outside file remains unreachable from + // the worktree (no path to it via any symlink we created). + const leak = await fs + .readFile(path.join(wt, 'escape-to-outside', 'secret'), 'utf8') + .catch(() => null); + expect(leak).toBeNull(); + } finally { + await fs.rm(outsideResolved, { recursive: true, force: true }); + } + }); + + it("rejects any entry containing a '..' segment (docs contract)", async () => { + // `foo/../bar` resolves to `bar` (inside the repo), so the + // post-resolve isWithinRoot check would accept it. But the + // user-facing description for `worktree.symlinkDirectories` + // promises rejection of any entry containing `..`. Verify the + // contract is enforced syntactically, before path.resolve. + // + // Provision a real `bar/` source so this test would fail loudly + // if the syntactic guard were removed (we'd see a symlink at + // `/bar` pointing back to the source). + await fs.mkdir(path.join(repoRoot, 'bar')); + await fs.writeFile(path.join(repoRoot, 'bar', 'marker'), 'bar'); + + const service = new GitWorktreeService(repoRoot); + const result = await service.createUserWorktree('dotdot', 'main', { + symlinkDirectories: ['foo/../bar'], + }); + expect(result.success).toBe(true); + + const wt = result.worktree!.path; + // Nothing at /bar (the resolved name)… + const bar = await fs + .lstat(path.join(wt, 'bar')) + .then(() => true) + .catch(() => false); + expect(bar).toBe(false); + // …nor at /foo (the raw first segment). + const foo = await fs + .lstat(path.join(wt, 'foo')) + .then(() => true) + .catch(() => false); + expect(foo).toBe(false); + }); + + it('handles multiple entries — some present, some missing', async () => { + await fs.mkdir(path.join(repoRoot, 'present-a')); + await fs.writeFile(path.join(repoRoot, 'present-a', 'x'), 'a'); + await fs.mkdir(path.join(repoRoot, 'present-b')); + await fs.writeFile(path.join(repoRoot, 'present-b', 'y'), 'b'); + + const service = new GitWorktreeService(repoRoot); + const result = await service.createUserWorktree('multi', 'main', { + symlinkDirectories: ['present-a', 'absent', 'present-b'], + }); + expect(result.success).toBe(true); + + const wt = result.worktree!.path; + expect(await fs.readlink(path.join(wt, 'present-a'))).toBe( + path.join(repoRoot, 'present-a'), + ); + expect(await fs.readlink(path.join(wt, 'present-b'))).toBe( + path.join(repoRoot, 'present-b'), + ); + // Absent: nothing created. + const absent = await fs + .lstat(path.join(wt, 'absent')) + .then(() => true) + .catch(() => false); + expect(absent).toBe(false); + }); + + // Phase D-3 sanity check: fetchPullRequestRef's error taxonomy. We + // keep happy-path PR-worktree coverage in cli/src/startup/worktreeStartup.test.ts + // (which exercises the full setupStartupWorktree → createUserWorktree + // flow against a local fake remote); here we just verify the error + // messages so reviewers can grep them in this file. + describe('Phase D-3: fetchPullRequestRef error messages', () => { + it('returns the "origin remote" error when origin is missing', async () => { + const service = new GitWorktreeService(repoRoot); + const res = await service.fetchPullRequestRef(1, { timeoutMs: 10000 }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error).toContain('#1'); + expect(res.error.toLowerCase()).toContain('origin'); + } + }); + + it('rejects out-of-range PR numbers without firing git', async () => { + const service = new GitWorktreeService(repoRoot); + // 0 + let res = await service.fetchPullRequestRef(0); + expect(res.success).toBe(false); + if (!res.success) expect(res.error.toLowerCase()).toContain('invalid'); + // negative + res = await service.fetchPullRequestRef(-5); + expect(res.success).toBe(false); + // absurdly large + res = await service.fetchPullRequestRef(9_999_999_999); + expect(res.success).toBe(false); + }); + + it('handles "no such ref" when origin is reachable but the PR does not exist', async () => { + // Set up a bare upstream with only main — no pull//head refs. + const upstream = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-wt-pr-no-such-ref-'), + ); + const upstreamResolved = await fs.realpath(upstream); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main'], { + cwd: upstreamResolved, + }); + execFileSync('git', ['remote', 'add', 'origin', upstreamResolved], { + cwd: repoRoot, + }); + execFileSync('git', ['push', '-q', 'origin', 'main'], { cwd: repoRoot }); + + try { + const service = new GitWorktreeService(repoRoot); + const res = await service.fetchPullRequestRef(99999, { + timeoutMs: 10000, + }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error).toContain('#99999'); + // Either the "PR does not exist" branch fired (preferred) or + // the generic "PR may not exist or origin unreachable" + // fallback — both are acceptable depending on the git version. + expect(res.error.toLowerCase()).toMatch( + /pr.*not exist|origin.*unreachable/, + ); + } + } finally { + await fs.rm(upstreamResolved, { recursive: true, force: true }); + } + }); + }); + + it('is a no-op when symlinkDirectories is omitted or empty', async () => { + await fs.mkdir(path.join(repoRoot, 'node_modules')); + const service = new GitWorktreeService(repoRoot); + + const noOpts = await service.createUserWorktree('no-opts', 'main'); + expect(noOpts.success).toBe(true); + const noOptsDest = path.join(noOpts.worktree!.path, 'node_modules'); + const noOptsExists = await fs + .lstat(noOptsDest) + .then(() => true) + .catch(() => false); + expect(noOptsExists).toBe(false); + + const emptyArr = await service.createUserWorktree('empty-arr', 'main', { + symlinkDirectories: [], + }); + expect(emptyArr.success).toBe(true); + const emptyArrDest = path.join(emptyArr.worktree!.path, 'node_modules'); + const emptyArrExists = await fs + .lstat(emptyArrDest) + .then(() => true) + .catch(() => false); + expect(emptyArrExists).toBe(false); + }); +}); diff --git a/packages/core/src/services/gitWorktreeService.test.ts b/packages/core/src/services/gitWorktreeService.test.ts index acfafc39e3f..ef46e06c82a 100644 --- a/packages/core/src/services/gitWorktreeService.test.ts +++ b/packages/core/src/services/gitWorktreeService.test.ts @@ -537,4 +537,74 @@ describe('GitWorktreeService', () => { expect(result.errors).toHaveLength(0); }); }); + + describe('parsePRReference', () => { + it('recognises #N shorthand', () => { + expect(GitWorktreeService.parsePRReference('#123')).toBe(123); + expect(GitWorktreeService.parsePRReference('#1')).toBe(1); + expect(GitWorktreeService.parsePRReference('#99999')).toBe(99999); + }); + + it('trims surrounding whitespace before matching', () => { + expect(GitWorktreeService.parsePRReference(' #42 ')).toBe(42); + }); + + it('rejects leading zeros to keep round-trips unambiguous', () => { + expect(GitWorktreeService.parsePRReference('#0123')).toBeNull(); + expect(GitWorktreeService.parsePRReference('#0')).toBeNull(); + }); + + it('recognises full GitHub PR URLs (any host)', () => { + expect( + GitWorktreeService.parsePRReference( + 'https://github.com/QwenLM/qwen-code/pull/4174', + ), + ).toBe(4174); + expect( + GitWorktreeService.parsePRReference( + 'http://gh.enterprise.example.com/team/repo/pull/9', + ), + ).toBe(9); + }); + + it('tolerates trailing slash, query string, and fragment', () => { + expect( + GitWorktreeService.parsePRReference('https://github.com/o/r/pull/123/'), + ).toBe(123); + expect( + GitWorktreeService.parsePRReference( + 'https://github.com/o/r/pull/123?foo=bar', + ), + ).toBe(123); + expect( + GitWorktreeService.parsePRReference( + 'https://github.com/o/r/pull/123#discussion_r999', + ), + ).toBe(123); + }); + + it('returns null for plain slugs and malformed inputs', () => { + expect(GitWorktreeService.parsePRReference('my-feature')).toBeNull(); + expect(GitWorktreeService.parsePRReference('#abc')).toBeNull(); + expect(GitWorktreeService.parsePRReference('123')).toBeNull(); + expect( + GitWorktreeService.parsePRReference('https://example.com/'), + ).toBeNull(); + expect( + GitWorktreeService.parsePRReference( + 'https://github.com/o/r/issues/123', + ), + ).toBeNull(); + expect(GitWorktreeService.parsePRReference('')).toBeNull(); + }); + + it('safely handles non-string input', () => { + expect( + GitWorktreeService.parsePRReference(undefined as unknown as string), + ).toBeNull(); + expect( + GitWorktreeService.parsePRReference(null as unknown as string), + ).toBeNull(); + }); + }); }); diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 270f05b4e24..64958377c1c 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -7,14 +7,17 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { randomBytes, randomInt } from 'node:crypto'; -import { execSync } from 'node:child_process'; +import { execFile, execSync } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); import { simpleGit, CheckRepoActions } from 'simple-git'; import type { SimpleGit } from 'simple-git'; import { Storage } from '../config/storage.js'; import { isCommandAvailable } from '../utils/shell-utils.js'; import { isNodeError } from '../utils/errors.js'; import { createDebugLogger } from '../utils/debugLogger.js'; -import { fileExists } from '../utils/fileUtils.js'; +import { fileExists, isWithinRoot } from '../utils/fileUtils.js'; import { initRepositoryWithMainBranch } from './gitInit.js'; const debugLogger = createDebugLogger('GIT_WORKTREE_SERVICE'); @@ -372,6 +375,26 @@ export class GitWorktreeService { return hash.trim(); } + /** + * Resolves a git ref name to a 40-char commit SHA. Returns `null` when + * the ref is unknown / unborn / not a commit. + * + * Used by Phase D-3 to lock in `FETCH_HEAD` immediately after + * `fetchPullRequestRef` succeeds, so the SHA passed to + * `git worktree add` is immutable against a concurrent `git fetch` from + * another process sharing the same repo, AND so `WorktreeExitDialog`'s + * `rev-list ..HEAD` counts only THIS session's new + * work rather than every commit in the fetched PR. + */ + async resolveRef(ref: string): Promise { + try { + const out = (await this.git.raw(['rev-parse', '--verify', ref])).trim(); + return /^[0-9a-f]{40}$/.test(out) ? out : null; + } catch { + return null; + } + } + /** * Creates a single worktree. */ @@ -1029,6 +1052,299 @@ export class GitWorktreeService { return `${adj}-${noun}-${suffix}`; } + /** + * Parses a PR reference from a string. Recognised forms: + * + * - `#123` — shorthand PR number + * - `https://github.com///pull/123` — full GitHub URL + * (any host, any query string, any fragment) + * + * Returns the parsed PR number on match, `null` otherwise. The slug for + * a PR worktree is derived by callers as `pr-` and the branch as + * `worktree-pr-` (see `createUserWorktree`). + * + * Mirrors claude-code's `parsePRReference` (utils/worktree.ts:633) so + * cross-CLI muscle memory transfers. + */ + static parsePRReference(input: string): number | null { + if (typeof input !== 'string') return null; + const trimmed = input.trim(); + + // GitHub-style PR URL: https:///owner/repo/pull/ + // - any host (public github.com or enterprise) + // - optional trailing slash, query string, or fragment + // - optional sub-path after `/pull//` (`/files`, `/commits`, + // `/checks`, etc.) — users routinely copy URLs while browsing + // files on a PR, and the PR number is still unambiguous + const urlMatch = trimmed.match( + /^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/i, + ); + if (urlMatch?.[1]) { + const n = parseInt(urlMatch[1], 10); + return Number.isSafeInteger(n) && n > 0 ? n : null; + } + + // `#N` shorthand. Reject leading zeros (`#0123`) to keep round-trips + // unambiguous — `gh pr view 0123` errors out anyway. + const hashMatch = trimmed.match(/^#([1-9]\d*)$/); + if (hashMatch?.[1]) { + const n = parseInt(hashMatch[1], 10); + return Number.isSafeInteger(n) && n > 0 ? n : null; + } + + return null; + } + + /** + * Identifies the registered worktree at `worktreePath` as a member of + * THIS repository (`sourceRepoPath`). Returns the branch + HEAD commit + * SHA on success, or `null` when the path is not a worktree of this + * repo. + * + * Used by Phase D-1's re-attach path: when `--worktree foo` is passed + * and `/.qwen/worktrees/foo` already exists on disk, we + * verify it really IS a Qwen-managed worktree of the current repo (not + * a standalone `git init` someone dropped at that path) before + * assuming it's safe to chdir into. Returning the HEAD SHA in the + * same call avoids a second subprocess to recapture it after chdir. + * + * Implementation — a single `git rev-parse` returning four lines: + * 1. `HEAD` → the worktree's HEAD commit SHA (must come BEFORE + * `--abbrev-ref` since the flag sticks for all subsequent refs). + * 2. `--abbrev-ref HEAD` → the branch name. A detached HEAD produces + * `HEAD` here, which we treat as "no real branch" and return null + * — the caller's re-attach gate will then refuse, since the + * slug-derived branch couldn't possibly be `HEAD`. + * 3. `--git-common-dir` → the common `.git` directory. For a real + * linked worktree of this repo that's `/.git`; + * for a sibling `git init` it resolves to `/.git`. + * We compare against this repo's own common-dir to reject the + * latter. + * 4. `--show-toplevel` → git's idea of the worktree top. For a real + * linked worktree this equals `worktreePath`; for a plain + * directory living UNDER the main repo (e.g. `mkdir + * /.qwen/worktrees/foo`) git walks up to the outer `.git` + * and returns the OUTER repo's root — which would otherwise pass + * the common-dir check and let us "re-attach" to a non-worktree + * directory. Compare paths to reject this. + */ + async getRegisteredWorktreeBranch( + worktreePath: string, + ): Promise<{ branch: string; headCommit: string } | null> { + let resolvedWorktreePath: string; + try { + const stat = await fs.stat(worktreePath); + if (!stat.isDirectory()) return null; + // `realpath` so macOS /var → /private/var canonicalises before + // the toplevel comparison below — otherwise a real worktree + // under /var/folders compares unequal to git's `/private/var/…` + // answer and we'd reject every legitimate re-attach on macOS. + resolvedWorktreePath = await fs.realpath(worktreePath); + } catch { + return null; + } + + // Run the two probes in parallel: this repo's common-dir comes from + // `this.git`, the candidate's HEAD-SHA + branch + common-dir + + // toplevel come from a fresh simple-git rooted at `worktreePath` + // via a single combined rev-parse. + const probeGit = simpleGit(worktreePath); + let ourCommonDir: string; + let headCommit: string; + let branch: string; + let probeCommonDir: string; + let probeToplevel: string; + try { + const [ourRaw, probeRaw] = await Promise.all([ + this.git.raw(['rev-parse', '--git-common-dir']), + probeGit.raw([ + 'rev-parse', + 'HEAD', + '--abbrev-ref', + 'HEAD', + '--git-common-dir', + '--show-toplevel', + ]), + ]); + ourCommonDir = path.resolve(this.sourceRepoPath, ourRaw.trim()); + const lines = probeRaw + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + if (lines.length < 4) return null; + headCommit = lines[0]!; + branch = lines[1]!; + probeCommonDir = path.resolve(worktreePath, lines[2]!); + probeToplevel = path.resolve(lines[3]!); + } catch (error) { + debugLogger.debug( + `getRegisteredWorktreeBranch: probe at ${worktreePath} failed: ${error}`, + ); + return null; + } + + if (probeCommonDir !== ourCommonDir) { + debugLogger.debug( + `getRegisteredWorktreeBranch: ${worktreePath} belongs to a different repo (common-dir=${probeCommonDir}, expected ${ourCommonDir})`, + ); + return null; + } + if (probeToplevel !== resolvedWorktreePath) { + // Plain directory under the main repo — git walked up and + // returned the outer repo's toplevel. Refuse to treat as a + // worktree. + debugLogger.debug( + `getRegisteredWorktreeBranch: ${worktreePath} is not a registered worktree (toplevel=${probeToplevel}, expected ${resolvedWorktreePath})`, + ); + return null; + } + if (!branch || branch === 'HEAD') return null; + return { branch, headCommit }; + } + + /** + * Fetches the GitHub PR ref `refs/pull//head` from the `origin` remote + * so a subsequent `createUserWorktree(..., 'FETCH_HEAD')` call can branch + * off the PR's tip (Phase D-3). Returns `{ success: true }` on success, + * or `{ success: false, error }` with a user-facing reason on failure. + * + * Implementation notes: + * + * - Uses `git fetch origin pull//head` (no `gh` CLI dependency). + * - Hard timeout of 30s by default — overridable for tests. A hung git + * process on a misconfigured corporate proxy would otherwise stall + * the entire startup sequence. + * - Does NOT create a local branch — leaves the ref accessible only + * via `FETCH_HEAD`. Subsequent `git worktree add -b + * FETCH_HEAD` materialises the worktree branch off it. + * + * Error message taxonomy is friendly because this is the user's first + * impression when their `--worktree=#` fails: + * - missing `origin` → tell them the remote is required + how to fix + * - timeout → mention the configured timeout so they can blame the network + * - generic failure → "PR may not exist or origin is unreachable" + */ + async fetchPullRequestRef( + prNumber: number, + options?: { timeoutMs?: number }, + ): Promise<{ success: true } | { success: false; error: string }> { + if ( + !Number.isSafeInteger(prNumber) || + prNumber <= 0 || + prNumber > 1_000_000_000 + ) { + // Out-of-range PR numbers can't sensibly hit GitHub. Reject locally + // rather than firing a doomed network call. + return { + success: false, + error: `Invalid PR number: ${prNumber}.`, + }; + } + const timeoutMs = options?.timeoutMs ?? 30_000; + + // Two-layer defense for the refspec argv element: + // + // 1. Regex digit-only validation at the call site — CodeQL's + // `js/second-order-command-line-injection` rule recognises + // `/^[1-9][0-9]*$/.test(x)` as a lexical sanitizer, which proves + // `prNumber` cannot resemble a `--upload-pack=…` flag. The + // entry guard above already establishes this at runtime, but + // CodeQL's interprocedural taint tracker doesn't see through + // that guard; the regex check IS the pattern its sanitizer + // library recognises. + // 2. `--end-of-options` as a git-runtime marker. Even though + // layer 1 makes a flag-shaped refspec impossible, the marker + // tells git definitively that every subsequent argv element + // is positional — defense-in-depth against a future + // regression that loosens the entry guard. + const prNumberStr = String(prNumber); + if (!/^[1-9][0-9]*$/.test(prNumberStr)) { + // Unreachable given the entry guard; here to make the + // lexical sanitizer visible to static analyzers. + return { + success: false, + error: `Invalid PR number: ${prNumber}.`, + }; + } + const refspec = `pull/${prNumberStr}/head`; + + try { + // Force English git stderr so the error-taxonomy regexes below + // match. Without this, users with non-English locales fall + // through to the generic "PR may not exist" branch even for + // well-known cases like missing-origin. The git binary itself is + // unaffected by LANG/LC_ALL beyond message strings. + await execFileAsync( + 'git', + ['fetch', '--end-of-options', 'origin', refspec], + { + cwd: this.sourceRepoPath, + timeout: timeoutMs, + env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, + }, + ); + return { success: true }; + } catch (error) { + // execFile reports timeouts via `signal: 'SIGTERM'` on the + // error object; the stderr text gives us the underlying git error. + const err = error as NodeJS.ErrnoException & { + stderr?: string | Buffer; + signal?: string; + }; + const stderr = + typeof err.stderr === 'string' + ? err.stderr + : err.stderr instanceof Buffer + ? err.stderr.toString('utf8') + : ''; + const lower = stderr.toLowerCase(); + + if (err.signal === 'SIGTERM') { + return { + success: false, + error: + `Failed to fetch PR #${prNumber}: timed out after ${Math.round(timeoutMs / 1000)}s. ` + + `Check network connectivity and any HTTP(S) proxy settings.`, + }; + } + if ( + lower.includes('does not appear to be a git repository') || + lower.includes('could not read from remote repository') || + lower.includes("'origin' does not appear") + ) { + return { + success: false, + error: + `--worktree=#${prNumber} requires an "origin" remote that points at GitHub. ` + + `Add one with \`git remote add origin \` and retry.`, + }; + } + if ( + lower.includes('no such ref') || + lower.includes("couldn't find remote ref") || + lower.includes("couldn't find remote ref pull/") + ) { + return { + success: false, + error: + `Failed to fetch PR #${prNumber}: the PR does not exist on origin, ` + + `or origin is not a GitHub repository (only GitHub exposes refs/pull//head).`, + }; + } + // Generic fallback. Include the stderr first line so an operator + // running with --debug can correlate, but keep it terse. + const firstLine = stderr.split('\n').find((l) => l.trim().length > 0); + const detail = firstLine ? ` (${firstLine.trim()})` : ''; + debugLogger.warn( + `fetchPullRequestRef: git fetch pull/${prNumber}/head failed: ${error}`, + ); + return { + success: false, + error: `Failed to fetch PR #${prNumber}: PR may not exist, or origin remote is unreachable${detail}.`, + }; + } + } + /** * Validates a worktree slug. Returns null on success, or an error message. * @@ -1089,6 +1405,7 @@ export class GitWorktreeService { async createUserWorktree( slug: string, baseBranch?: string, + options?: { symlinkDirectories?: readonly string[] }, ): Promise { const validationError = GitWorktreeService.validateUserWorktreeSlug(slug); if (validationError) { @@ -1152,6 +1469,22 @@ export class GitWorktreeService { ); }); + // Phase D-2: symlink user-configured directories from the main + // repo into the new worktree (e.g. node_modules) so the model can + // run tests / builds without a fresh install. Same fail-open + // policy as hooksPath — failures log and continue. + const symlinkPaths = options?.symlinkDirectories ?? []; + if (symlinkPaths.length > 0) { + await this.symlinkConfiguredDirectories( + worktreePath, + symlinkPaths, + ).catch((error) => { + debugLogger.warn( + `createUserWorktree: symlinkConfiguredDirectories failed for ${slug}: ${error}`, + ); + }); + } + const worktree: WorktreeInfo = { id: slug, name: slug, @@ -1252,6 +1585,277 @@ export class GitWorktreeService { } } + /** + * Phase D-2 symlink loop. For each configured directory under the main + * repository, creates a symbolic link from the new worktree to the + * main-repo location (`/` → `/`). + * + * Fail-open semantics — the worktree IS already on disk and usable by + * the time this runs, so a symlink failure must NOT abort the parent + * `createUserWorktree` call. Per-entry failures are logged at debug or + * warn level depending on cause: + * + * - **ENOENT on source** (the main repo does not have the directory): + * debug log, skip. Typical for users who configure `node_modules` + * but launch from a fresh clone where `npm install` hasn't run yet. + * - **EEXIST on destination** (something already lives at the symlink + * target inside the worktree): debug log, skip. No overwrite; the + * existing content (whether file, dir, or stale link) wins. + * - **Absolute path or path traversal in the configured value**: + * warn log, skip the entry. Configured values must stay relative to + * the repo root to prevent a setting from redirecting writes onto + * `/etc`, `~`, or anywhere outside the repo subtree. + * - **Other I/O errors**: warn log, continue to the next entry. + * + * Mirrors claude-code's `symlinkDirectories` helper (utils/worktree.ts). + */ + private async symlinkConfiguredDirectories( + worktreePath: string, + configured: readonly string[], + ): Promise { + // Loop-invariant canonical paths, hoisted out of the per-entry loop. + // + // We must `fs.realpath` the repo root (rather than `path.resolve`, + // which is purely lexical) so every containment check below compares + // canonical paths to canonical paths. The post-stat `realSource = + // fs.realpath(sourceAbs)` produces a canonical path, and on any + // system where the repo path contains a symlink component (macOS + // `/tmp → /private/tmp` is ubiquitous; user-symlinked source trees on + // Linux/Windows too) the lexical `path.resolve(sourceRepoPath)` does + // not share a prefix with that canonical realpath. Without this hoist + // `isWithinRoot(realSource, repoRootAbs)` silently rejects EVERY + // configured entry — cf. PR #4381 round 8 regression. + let repoRootAbs: string; + try { + repoRootAbs = await fs.realpath(this.sourceRepoPath); + } catch { + // realpath of a non-existent / inaccessible repo root is fatal for + // the symlink loop's containment checks (we can't validate against + // a path we can't canonicalise). Bail out — the worktree itself is + // already on disk so this is non-destructive; we just skip the + // opt-in symlink step. + debugLogger.warn( + `symlinkConfiguredDirectories: cannot realpath sourceRepoPath "${this.sourceRepoPath}", skipping all entries`, + ); + return; + } + const gitDirAbs = path.join(repoRootAbs, '.git'); + const qwenDirAbs = path.join(repoRootAbs, '.qwen'); + // Same canonical-vs-canonical requirement for the dest side. The + // worktree was just created by `git worktree add`, so the path + // should exist; fall back to the input path on realpath error so a + // weird-but-extant worktree path doesn't deadlock the whole loop. + const realWorktreePath = await fs + .realpath(worktreePath) + .catch(() => worktreePath); + + for (const raw of configured) { + if (typeof raw !== 'string' || raw.length === 0) { + debugLogger.warn( + `symlinkConfiguredDirectories: skipping non-string / empty entry: ${JSON.stringify(raw)}`, + ); + continue; + } + + // Reject absolute paths and any traversal-prone form. Resolve first + // to catch `./foo/../../etc` style escapes that look relative. + if (path.isAbsolute(raw)) { + debugLogger.warn( + `symlinkConfiguredDirectories: refusing absolute path "${raw}"`, + ); + continue; + } + // Reject any literal `..` segment up front. The post-resolve + // `isWithinRoot` check below would still accept `foo/../bar` + // (resolves to `bar`, which is inside the repo), but the public + // contract — settingsSchema description, docs/users/features/ + // worktree.md, WorktreeSettings JSDoc — promises rejection of + // any entry containing `..`. Enforce that promise here. + if (raw.split(/[\\/]/).includes('..')) { + debugLogger.warn( + `symlinkConfiguredDirectories: refusing path "${raw}" — contains '..' segment`, + ); + continue; + } + const sourceAbs = path.resolve(repoRootAbs, raw); + if (sourceAbs === repoRootAbs) { + // `""` / `"."` / `"./"` etc. — pointless and would alias the + // entire repo into itself. Reject explicitly so the path-prefix + // checks below don't have to handle this degenerate case. + debugLogger.warn( + `symlinkConfiguredDirectories: refusing empty / repo-root path "${raw}"`, + ); + continue; + } + if (!isWithinRoot(sourceAbs, repoRootAbs)) { + debugLogger.warn( + `symlinkConfiguredDirectories: refusing path "${raw}" — resolves outside repo root (${sourceAbs} vs ${repoRootAbs})`, + ); + continue; + } + + // Refuse to symlink git-internal paths into the worktree. `.git` + // would silently break commits / status / diff inside the + // worktree (the worktree's own gitlink file points at the parent + // common-dir, and a symlink would shadow it). The whole `.qwen` + // tree is also off-limits: linking `.qwen` (parent) would + // recursively pull `.qwen/worktrees` into the new worktree, + // recreating the loop; linking `.qwen/worktrees` directly + // creates the same loop more obviously; and `.qwen/projects` + // / `.qwen/tmp` are CLI metadata users have no legitimate + // reason to share across worktrees. + // `gitDirAbs` / `qwenDirAbs` are canonical (derived from the + // realpath'd `repoRootAbs` hoisted above the loop), so these + // comparisons stay consistent with the post-stat realpath check. + if (isWithinRoot(sourceAbs, gitDirAbs)) { + debugLogger.warn( + `symlinkConfiguredDirectories: refusing git-internal path "${raw}"`, + ); + continue; + } + if (isWithinRoot(sourceAbs, qwenDirAbs)) { + debugLogger.warn( + `symlinkConfiguredDirectories: refusing path "${raw}" — ` + + `the .qwen tree is CLI-managed; symlinking any of it could ` + + `create a worktrees-inside-worktrees loop or alias CLI metadata.`, + ); + continue; + } + + // Confirm the source exists. We don't insist on it being a directory + // specifically — `node_modules` is canonically a dir, but a user + // who wants to share a single file (`.env`, `secrets.json`) via + // `symlinkDirectories` should still get the link. + let sourceStat: { isDirectory: () => boolean } | null = null; + try { + sourceStat = await fs.stat(sourceAbs); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + debugLogger.debug( + `symlinkConfiguredDirectories: source missing, skipping: ${sourceAbs}`, + ); + } else { + debugLogger.warn( + `symlinkConfiguredDirectories: cannot stat ${sourceAbs}: ${error}`, + ); + } + continue; + } + + // Resolve through any symlinks in the source path and RE-RUN the + // containment + blocklist checks against the realpath. The lexical + // checks above only see `path.resolve(repoRoot, raw)` — they can't + // tell that `/node_modules` is actually a symlink chaining + // into `.git`, an outside dir, or `.qwen`. Without this step a + // committed-or-out-of-band source symlink bypasses every guard the + // lexical loop set up. Use the realpath as the symlink target so + // the new link points canonically rather than preserving the chain. + let realSource: string; + try { + realSource = await fs.realpath(sourceAbs); + } catch (error) { + debugLogger.warn( + `symlinkConfiguredDirectories: cannot realpath source "${sourceAbs}": ${error}`, + ); + continue; + } + if (!isWithinRoot(realSource, repoRootAbs)) { + debugLogger.warn( + `symlinkConfiguredDirectories: refusing path "${raw}" — real source ${realSource} escapes repo root ${repoRootAbs}`, + ); + continue; + } + if (isWithinRoot(realSource, gitDirAbs)) { + debugLogger.warn( + `symlinkConfiguredDirectories: refusing path "${raw}" — real source ${realSource} resolves inside .git`, + ); + continue; + } + if (isWithinRoot(realSource, qwenDirAbs)) { + debugLogger.warn( + `symlinkConfiguredDirectories: refusing path "${raw}" — real source ${realSource} resolves inside .qwen`, + ); + continue; + } + + const destAbs = path.join(worktreePath, raw); + + // Ensure the parent directory of `destAbs` exists. For top-level + // entries (`node_modules`) this is a no-op against the worktree + // root, but for nested values (`tools/cache`) we may need to + // create the intermediate dirs first — git worktree add does NOT + // create them. + try { + await fs.mkdir(path.dirname(destAbs), { recursive: true }); + } catch (error) { + debugLogger.warn( + `symlinkConfiguredDirectories: cannot mkdir parent of ${destAbs}: ${error}`, + ); + continue; + } + + // Sibling-drift defense to the round-7 source-side realpath check: + // `path.join(worktreePath, raw)` is lexical too. If `git worktree + // add` materialized a committed symlink under the worktree + // (e.g. HEAD ships `tools → /etc`), then the OS-side resolution + // of `/tools/cache` traverses through the committed + // symlink and our `fs.mkdir` / `fs.symlink` write OUTSIDE the + // worktree. Realpath the dest parent and refuse if it escapes. + let realDestParent: string; + try { + realDestParent = await fs.realpath(path.dirname(destAbs)); + } catch (error) { + debugLogger.warn( + `symlinkConfiguredDirectories: cannot realpath dest parent for "${raw}" (${path.dirname(destAbs)}): ${error}`, + ); + continue; + } + if (!isWithinRoot(realDestParent, realWorktreePath)) { + debugLogger.warn( + `symlinkConfiguredDirectories: refusing path "${raw}" — dest parent ${realDestParent} escapes worktree root ${realWorktreePath} (committed-symlink chain)`, + ); + continue; + } + + // `fs.symlink` rejects with EEXIST when the destination already + // exists. Treat that as "user already populated this slot, leave + // it alone" — same as claude-code's behavior. + try { + // On Windows, `fs.symlink(..., 'dir')` requires + // SeCreateSymbolicLinkPrivilege (administrator rights, or + // Developer Mode + unprivileged-symlink-creation enabled) and + // EPERMs on default consumer installs. A junction is a reparse + // point that achieves the same "this path resolves over there" + // semantics for directories without elevation. `'file'` symlinks + // on Windows also need the same privilege but there's no + // junction-equivalent for files, so we leave `'file'` as-is and + // accept the EPERM fall-through for the rare file-symlink case. + const symlinkType = sourceStat.isDirectory() + ? process.platform === 'win32' + ? 'junction' + : 'dir' + : 'file'; + // Point at the canonical realpath rather than the lexical + // `sourceAbs` so the new link is one-hop and doesn't preserve + // the chain we just validated. + await fs.symlink(realSource, destAbs, symlinkType); + debugLogger.debug( + `symlinkConfiguredDirectories: linked ${destAbs} → ${realSource} (${symlinkType})`, + ); + } catch (error) { + if (isNodeError(error) && error.code === 'EEXIST') { + debugLogger.debug( + `symlinkConfiguredDirectories: destination exists, skipping: ${destAbs}`, + ); + } else { + debugLogger.warn( + `symlinkConfiguredDirectories: failed to link ${destAbs} → ${realSource}: ${error}`, + ); + } + } + } + } + /** * Returns true if a local branch with the given name exists. * diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 05f8cc2bd3f..7ca71cf2a98 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1551,7 +1551,9 @@ class AgentToolInvocation extends BaseToolInvocation { `[Agent] getCurrentBranch failed at ${projectRoot}: ${error}`, ); } - const created = await wtService.createUserWorktree(slug, parentBranch); + const created = await wtService.createUserWorktree(slug, parentBranch, { + symlinkDirectories: this.config.getWorktreeSymlinkDirectories(), + }); if (!created.success || !created.worktree) { return failWorktreeProvisioning( `Failed to create isolation worktree: ${created.error ?? 'unknown error'}`, diff --git a/packages/core/src/tools/enter-worktree.session.integ.test.ts b/packages/core/src/tools/enter-worktree.session.integ.test.ts index 09bd6dbcad0..06535ac3fda 100644 --- a/packages/core/src/tools/enter-worktree.session.integ.test.ts +++ b/packages/core/src/tools/enter-worktree.session.integ.test.ts @@ -58,6 +58,9 @@ describe('EnterWorktreeTool — WorktreeSession sidecar', () => { getTargetDir: () => repoRoot, getSessionId: () => sessionId, getSessionService: () => sessionService, + // Phase D-2: createUserWorktree reads this for the symlink loop. + // Return empty so the loop is a no-op in these tests. + getWorktreeSymlinkDirectories: () => [], } as unknown as Config; } diff --git a/packages/core/src/tools/enter-worktree.ts b/packages/core/src/tools/enter-worktree.ts index 12f8320174a..5962c5dfb13 100644 --- a/packages/core/src/tools/enter-worktree.ts +++ b/packages/core/src/tools/enter-worktree.ts @@ -161,7 +161,9 @@ class EnterWorktreeInvocation extends BaseToolInvocation< ); } - const result = await service.createUserWorktree(slug, baseBranch); + const result = await service.createUserWorktree(slug, baseBranch, { + symlinkDirectories: this.config.getWorktreeSymlinkDirectories(), + }); if (!result.success || !result.worktree) { const reason = result.error ?? 'Failed to create worktree.'; debugLogger.warn(`enter_worktree: createUserWorktree failed: ${reason}`); diff --git a/packages/core/src/tools/exit-worktree.session.integ.test.ts b/packages/core/src/tools/exit-worktree.session.integ.test.ts index 8c954b8e533..2ac958462b0 100644 --- a/packages/core/src/tools/exit-worktree.session.integ.test.ts +++ b/packages/core/src/tools/exit-worktree.session.integ.test.ts @@ -57,6 +57,9 @@ describe('ExitWorktreeTool — WorktreeSession sidecar cleanup', () => { getTargetDir: () => repoRoot, getSessionId: () => sessionId, getSessionService: () => sessionService, + // Phase D-2: EnterWorktreeTool (used here for setup) reads this + // setting; return empty so the symlink loop is a no-op. + getWorktreeSymlinkDirectories: () => [], } as unknown as Config; } diff --git a/packages/core/src/tools/exit-worktree.test.ts b/packages/core/src/tools/exit-worktree.test.ts index df021628e1c..f1ab9b83664 100644 --- a/packages/core/src/tools/exit-worktree.test.ts +++ b/packages/core/src/tools/exit-worktree.test.ts @@ -27,6 +27,10 @@ function makeMockConfig(targetDir = process.cwd()): Config { return { getTargetDir: vi.fn(() => targetDir), getSessionId: vi.fn(() => 'mock-session-id'), + // Phase D-2: EnterWorktreeTool (used here for setup) reads this + // setting when creating a worktree. Return empty so the symlink + // loop is a no-op in tests. + getWorktreeSymlinkDirectories: vi.fn(() => []), } as unknown as Config; } @@ -181,6 +185,7 @@ describe('ExitWorktreeTool', () => { const enterCfg = { getTargetDir: () => repoRoot, getSessionId: () => 'session-creator', + getWorktreeSymlinkDirectories: () => [], } as unknown as Config; const enter = new EnterWorktreeTool(enterCfg); const inv = enter.build({ name: slug }); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 9ea83878b01..ccf506ec5e7 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -2239,6 +2239,19 @@ } } }, + "worktree": { + "description": "Configuration for general-purpose git worktrees created by the CLI (the `enter_worktree` tool, the `agent isolation: \"worktree\"` parameter, and the startup `--worktree` flag). Does NOT affect Agent Arena worktrees — see `agents.arena.worktreeBaseDir` for those.", + "type": "object", + "properties": { + "symlinkDirectories": { + "description": "Directories under the main repository to symlink into every general-purpose worktree on creation. Useful for sharing large opt-in dirs like `node_modules` so the model can run tests / builds inside the worktree without a fresh install. Paths must be relative to the repo root; absolute paths, anything containing `..`, and any path inside `.git` or `.qwen` (the CLI-managed metadata tree, which contains the worktrees directory itself) are rejected. Missing source dirs and existing destination paths are silently skipped (no overwrite, no failure).", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "$version": { "type": "number", "description": "Settings schema version for migration tracking.",