qwen-code/docs/design/worktree.md
顾盼 5ad5301805
feat(worktree): Phase D — startup --worktree flag + symlinkDirectories + PR refs (#4381)
* feat(worktree): Phase D — startup --worktree flag + symlinkDirectories + PR refs

Three cross-cutting capabilities on top of the Phase A-C worktree
foundation (PRs #4073, #4174).

D-1: --worktree [name] CLI flag creates a worktree (or re-attaches to
one that already exists) before any model turn runs. Supports bare,
plain-slug, `=`, and PR-reference forms; --worktree + --acp rejected
with a clear error; --worktree + --resume overrides the resumed
session's saved sidecar and emits a stderr line.

D-2: worktree.symlinkDirectories: string[] settings key opts into
symlinking main-repo directories (e.g. node_modules) into every
newly-created general-purpose worktree. Applies to all three creation
paths: --worktree flag, EnterWorktreeTool, AgentTool isolation. Path
traversal, absolute paths, and existing destinations all guarded;
missing source dirs and EEXIST silently skipped (fail-open).

D-3: --worktree=#<N> / --worktree <github-url> resolves a PR number,
runs `git fetch origin pull/<N>/head` (30s timeout, no `gh` CLI
dependency, LANG=C for stable error-taxonomy matching), and creates
the worktree off FETCH_HEAD. URL regex tolerates /files, /commits,
/checks sub-paths so users can paste any GitHub PR URL.

Phase 6 verification fixes also included:
- Re-attach to an existing worktree instead of failing with "Worktree
  already exists" — the common `qwen --resume <sid> --worktree foo`
  workflow now succeeds. The session ownership marker is preserved on
  re-attach so cross-session exit_worktree action="remove" still fails
  for non-owners.
- Normalize path-taking argv fields (mcpConfig, jsonSchema @<path>,
  openaiLoggingDir, jsonFile, inputFile, telemetryOutfile,
  includeDirectories) to absolute paths against the launch cwd BEFORE
  the worktree chdir. Otherwise downstream fs.existsSync('./mcp.json')
  resolves into the worktree, where the file doesn't exist.

Phase 7 code-review fixes:
- buildStartupWorktreeNotice differentiates "Active worktree" (fresh
  create) from "Re-attached to worktree" (re-attach path).
- Notice survives sidecar persist failure: set before the try block,
  refreshed inside with override addendum if persist succeeded.
- getRegisteredWorktreeBranch verifies the candidate path's git
  common-dir matches the source repo's — rejects sibling `git init`
  directories that happen to be on a worktree-<slug> branch.

Three-mode parity for the startup notice: TUI consumes via
AppContainer effect, headless prepends a <system-reminder> + emits a
worktree_started JSON event. ACP path is mutually exclusive with
--worktree (ACP hosts supply per-session cwd separately).

Tests (66 + 15 new):
- 15 cli/src/startup/worktreeStartup.test.ts (slug forms, PR fetch
  against local fake remote, re-attach happy + wrong-branch guard)
- 8 core/src/services/gitWorktreeService.test.ts (parsePRReference:
  #N, URLs, malformed, traversal, leading zeros, non-string)
- 10 core/src/services/gitWorktreeService.symlinks.integ.test.ts
  (symlink loop + fetchPullRequestRef error taxonomy)

Known limitations (documented in docs/users/features/worktree.md):
- Cross-slug --resume <sid> --worktree <different-new-slug> is
  unsupported by design (sessions are bound to projectHash(cwd));
  future Config refactor anchoring storage at repo root would lift this.
- Mid-session enter_worktree still does NOT switch cwd/targetDir
  (Phase A's simplification); only the startup --worktree flag does.
- yargs ambiguity: `qwen --worktree "say hi"` consumes the prompt as
  the slug. Quick Start shows the `=` form and reordering workarounds.

Docs:
- docs/users/features/worktree.md (new): Quick Start with --worktree
  flag, CLI Reference table for all four input forms + error codes,
  settings table, Limitations.
- docs/design/worktree.md: Phase D section expanded into D-1/D-2/D-3
  with open questions resolved; capability table updated.
- docs/e2e-tests/worktree-phase-d.md (new): full E2E plan with Phase 4
  dry-run baseline + Phase 6 post-impl reproduction tables.

Refs #4056

* refactor(worktree): apply self-review feedback on Phase D

Self-review pass over the Phase D commit (2636f59273) catching one real
typecheck regression plus a batch of small quality + efficiency
improvements. No user-visible behavior change beyond fixing the build.

Build fix:
- worktreeStartup.ts imports — pre-commit prettier had reorganized
  `writeWorktreeSession` and `readWorktreeSession` under an
  `import type { ... }` block, erasing them at compile time
  (verbatimModuleSyntax). `tsc --noEmit` was failing with TS1361.
  Bundle path still worked (esbuild is lenient) so this only surfaced
  when running typecheck.

Startup-path efficiency (~10-25 ms saved per --worktree invocation on
macOS; more on Windows):
- Drop redundant `isGitRepository()` probe — `getRepoTopLevel()`
  returns null on non-git paths and covers both gates in one
  subprocess.
- Run `getCurrentBranch()` + `getCurrentCommitHash()` in parallel via
  Promise.all (independent calls).
- Combine the two `git rev-parse` probes inside
  `getRegisteredWorktreeBranch` into a single multi-arg call, and run
  it in parallel with the source-repo common-dir lookup. Saves one
  fork+exec on the re-attach path.

Quality:
- Extract `withReminder()` local helper in nonInteractiveCli.ts so the
  startup-notice and resume-restore branches share the system-reminder
  wrapping.
- Log `readWorktreeSession` failures in `persistStartupWorktreeSidecar`
  with the sidecar path so operators can recover the previous slug
  from a backup. Silent swallow was making "where did my worktree
  binding go?" undebuggable.
- Drop the dead `Config.getWorktreeSettings()` accessor (only
  `getWorktreeSymlinkDirectories()` has callers); keep the underlying
  `WorktreeSettings` interface for future fields.
- Document the `pendingStartupWorktreeNotice` invariant: at most one
  consumer per process; ACP path is gated out earlier so only TUI XOR
  headless reads it.
- Add a maintainer note in the gemini.tsx path-normalization block:
  the argv path-field allowlist is hand-maintained, register new
  path-bearing flags there or `--worktree` silently breaks for them.
- Drop `Phase 6 fix (G1)/(G2)` parenthetical labels from inline
  comments — internal review-cycle identifiers that decay to noise
  post-merge. Substantive prose retained.

Tests: cli 15/15 (unchanged) + core 66/66 (unchanged); bundle smoke
verified fresh / re-attach / invalid slug / non-git cases.

Findings deliberately left for follow-up:
- Larger refactor extracting a shared `provisionUserWorktree` helper
  for the EnterWorktreeTool / startup overlap (~80% duplicate).
- Splitting the re-attach branch out of `setupStartupWorktree` into
  its own function.
- `isPathWithinRoot` / `isInsideManagedWorktree` shared utils.
- `symlinkConfiguredDirectories` loop concurrency (saves 5-15 ms on a
  cold path that runs only when symlinkDirectories is configured).

* docs(worktree): refresh stale docstring in worktreeStartup

Top-of-file docstring still said `{adj}-{noun}-{4hex}` (actual format
is 6 hex chars) and described the PR form as "detected and rejected
with a clear 'coming in D-3' message" — but D-3 shipped in the same
PR. Tighten to reflect what the code actually does.

* fix(worktree): address findings from dual-reviewer self-check

Two real bugs surfaced by an independent dual-reviewer pass (Claude +
Codex) on the Phase D commits. Both correctness-affecting; both
escaped the earlier internal reviews.

P0 — re-attach captured the wrong baseline for the exit dialog
(Codex):
  setupStartupWorktree captured `originalHeadCommit` from the launch
  cwd (main checkout) before any chdir. On the re-attach path the
  WorktreeExitDialog later runs `git rev-list <originalHeadCommit>..HEAD`
  inside the worktree to count "new commits this session". With the
  main-checkout baseline this counted every commit ever made in the
  kept worktree as new work from the current session — misleading the
  keep/remove prompt. Re-capture HEAD from inside the worktree after
  chdir so the count means what the dialog text says it means.

P0 — getRegisteredWorktreeBranch mis-identified plain directories as
registered worktrees (Claude):
  A plain directory at `<repo>/.qwen/worktrees/<slug>/` (e.g. a stale
  artifact from a previous tool) had no `.git` file of its own, so
  `git rev-parse --git-common-dir` walked up to the outer repo and
  returned the outer common-dir — matching the source repo's
  common-dir check and impersonating a registered worktree. If the
  outer repo happened to be on `worktree-<slug>`, setupStartupWorktree
  would silently chdir into the plain directory and treat it as
  attached; subsequent `exit_worktree action="remove"` would then
  delete a directory that was never registered.
  Fix: also probe `--show-toplevel` and require it to equal the
  candidate path (canonicalised via `realpath` so macOS /var → /private/var
  doesn't break the equality check). A plain dir under the main repo
  gets the outer repo's toplevel and is correctly rejected.

Smaller polish from the same review:
- Normalize the literal string `'HEAD'` returned by `getCurrentBranch`
  on detached HEAD to `undefined`, so the `baseRef` handed to
  `git worktree add -b … HEAD` does not implicitly anchor against
  the loose commit when the launch cwd is detached.
- `symlinkConfiguredDirectories`: blocklist `.git` (any nested
  ancestor) and `.qwen/worktrees` (any nested ancestor). Linking
  `.git` would silently break commits inside the worktree; linking
  `.qwen/worktrees` would create a worktrees-inside-worktrees loop
  that confuses the startup sweep.
- `WorktreeSettings.symlinkDirectories` typed `readonly string[]` to
  match the `createUserWorktree(options.symlinkDirectories)` contract
  and the immutable-config convention elsewhere. `Config.getWorktreeSymlinkDirectories()`
  return type updated to match.

Docs:
- design/worktree.md precedence table rewritten. The previous
  `--worktree` 赢 row was unreachable in practice (sessions are bound
  to `projectHash(cwd)`, and the chdir happens before session lookup).
  New table reflects what actually happens for each combination of
  `--resume` × `--worktree`, including the documented
  cross-projectHash limitation. The `persistStartupWorktreeSidecar`
  override branch is now annotated as dead-on-the-current-architecture
  but kept so a future Config refactor (anchor storage at repo root)
  picks it up for free.

Tests: cli 15/15 + core 66/66 unchanged. Bundle smoke confirms both
P0 fixes end-to-end (re-attach captures worktree HEAD = run-1 tip,
plain-dir attempt errors out without clobbering existing content).

* refactor(worktree): consolidate probe + name detached-HEAD sentinel

Second /simplify pass on the dual-reviewer fixes. Three convergent
findings; net effect is one fewer subprocess on the re-attach path
and clearer intent on string handling / blocklist guards.

Efficiency + quality:
- Fold the worktree HEAD SHA into `getRegisteredWorktreeBranch`'s
  combined rev-parse. The probe already requests common-dir,
  toplevel, and abbrev-ref HEAD in a single subprocess; adding a
  leading `HEAD` positional (which must come BEFORE `--abbrev-ref` so
  the flag doesn't apply to it) returns the SHA on its own line.
  Return type widened to `{ branch, headCommit } | null`. Removes
  the second `GitWorktreeService` instantiation and `getCurrentCommitHash`
  call that `setupStartupWorktree`'s re-attach branch used to do.

Quality:
- Hoist `'HEAD'` to a module-level `DETACHED_HEAD` constant in
  `worktreeStartup.ts`. Three uses, two meanings (input filter when
  normalizing `getCurrentBranch` output, fallback metadata for the
  sidecar's `originalBranch` field on detached state). Naming the
  sentinel makes intent self-documenting and pre-empts the "why is
  the value we just stripped re-appearing as a fallback?" reader stall
  flagged by the round-3 quality review.

Reuse + quality:
- `symlinkConfiguredDirectories`: replace two hand-rolled containment
  checks (`startsWith(prefix + sep)` for `.qwen/worktrees`; `path.relative(...).split(sep)[0]`
  for `.git`) with `isWithinRoot` from `utils/fileUtils.ts`, which is
  already imported in this file. Replace the hardcoded
  `path.join(repoRootAbs, '.qwen', 'worktrees')` with `this.getUserWorktreesDir()`
  so the layout lives in one place (the exported `WORKTREES_DIR`
  constant). Split the misleading `sourceAbs === repoRootAbs` clause
  out of the `.git` branch into its own dedicated "empty / repo-root
  path" rejection with a clearer warn message.

Tests: cli 15/15 + core 66/66 unchanged. Bundle smoke verified the
folded probe still captures the worktree's HEAD on re-attach (not
the launch-cwd HEAD).

Skipped from this review pass:
- Moving `'HEAD'` normalization into `GitWorktreeService.getCurrentBranch()`
  itself — would ripple through `enter-worktree.ts` and `agent.ts`
  callers that hand the result verbatim to `git worktree add -b ...`.
  Out of scope for a polish pass; the local const is enough.

* fix(worktree): broaden symlink blocklist from .qwen/worktrees to all of .qwen

Caught by a second pr-tracker dual-reviewer pass (Codex). The previous
guard at `symlinkConfiguredDirectories` only refused paths inside
`<repoRoot>/.qwen/worktrees/` — `.qwen` itself (the parent) sailed
through because `isWithinRoot` is a strict descendant check. A user
setting `symlinkDirectories: ['.qwen']` would therefore symlink the
entire CLI metadata tree into the new worktree, recursively pulling
in `.qwen/worktrees` and recreating the loop the guard was meant to
prevent. Other `.qwen/*` subtrees (`projects`, `tmp`, …) are CLI
state with no legitimate cross-worktree sharing use case either.

Fix: broaden the guard to reject the whole `<repoRoot>/.qwen` tree.
Both `.qwen` itself and any descendant fail closed.

Also synced the user-facing settings schema description (the in-IDE
help text and the published JSON schema) so it mentions the `.git`
and `.qwen` rejection rules. The `WorktreeSettings` interface JSDoc
already mentioned them; the schema description had not been updated.

Tests: cli 15/15 + core 66/66 unchanged. Smoke confirms `--worktree foo`
with `symlinkDirectories: ['.qwen']` configured leaves the worktree
free of any `.qwen` symlink (only the legitimate per-worktree
`.qwen-session` marker file appears).

* fix(worktree): guard fetchPullRequestRef against CodeQL command-injection alert

CodeQL flagged a "Second order command injection" finding (rule 235) on
the `git fetch origin pull/<N>/head` call in `fetchPullRequestRef`. The
taint analyzer doesn't see the type-narrowing at the function entry
(`Number.isSafeInteger(prNumber) && prNumber > 0 && prNumber <= 1e9`),
so it considers `prNumber` library input that could in principle reach
a `--upload-pack=…`-shaped flag and thereby execute an arbitrary
program. In practice the entry guard already prevents that, but the
alert blocks the CodeQL CI check.

Add `--end-of-options` between `origin` and the refspec — git's
canonical "stop parsing flags" marker (git ≥ 2.24). Tells git
definitively that every subsequent argv element is a positional, not
a flag, which (a) satisfies the analyzer, (b) adds defense-in-depth
against a future regression that might relax the entry guard, and
(c) has zero behavior change for any well-formed PR number.

Verified locally: `git fetch --end-of-options origin pull/<N>/head`
against a local bare-remote with a seeded `refs/pull/42/head` still
fetches the ref correctly; the `--worktree=#42` smoke test reads back
the PR content from the materialized worktree.

Tests: cli 15/15 + core 66/66 unchanged.

* fix(worktree): lexical sanitizer for CodeQL + missing test mock entry

Two fixes from the third CI round on PR #4381:

1. CodeQL re-fires (round 2 of the same finding).

`--end-of-options` is a git-runtime defense, not a lexical sanitizer
that CodeQL's `js/second-order-command-line-injection` taint tracker
recognises. The alert re-fired against the same call after the
previous fix.

Switch to a CodeQL-recognised sanitizer: validate the numeric
component against `/^[1-9][0-9]*$/` immediately at the sink. The
regex digit-only check is one of the documented sanitizer patterns
the rule looks for, and proves at the analyzer level that the
resulting argv element cannot resemble a flag (`--foo`). The entry
guard at the top of the function still establishes the same fact
at runtime; this layer makes the proof visible to static analysis.
Keep `--end-of-options` as a runtime fallback against any future
regression that loosens the entry guard.

2. `nonInteractiveCli.test.ts` mock was missing the new
   `consumePendingStartupWorktreeNotice` Config method.

Phase D-1 added the method on `Config` and `nonInteractiveCli`
calls it on every prompt to pick up the one-shot startup-worktree
notice. The test file's `mockConfig` literal was not updated, so
all 19 `runNonInteractive` tests threw
`TypeError: config.consumePendingStartupWorktreeNotice is not a
function` on Ubuntu / macOS CI.

Add a stub returning `null` so the helper short-circuits, matching
the equivalent Phase C stub for `getResumedSessionData`.

Local: cli (worktreeStartup + nonInteractiveCli) 60 passed + 1
skipped; core (gitWorktreeService + symlinks + hooks +
enter-worktree) 66 passed.

* test(worktree): mock getWorktreeSymlinkDirectories in three more test files

Round 4 of the same Phase D-2 mock-drift class. CI surfaced 9 test
failures across three files whose `Config` mocks construct
`EnterWorktreeTool` for setup but lack the new
`getWorktreeSymlinkDirectories` method `createUserWorktree` now
calls:

- enter-worktree.session.integ.test.ts (2 tests)
- exit-worktree.session.integ.test.ts (3 tests) — provisions
  worktrees via EnterWorktreeTool before exercising exit paths
- exit-worktree.test.ts (4 tests) — same provisioning pattern via
  `provisionWorktree()` and the `makeMockConfig` helper

Add a `getWorktreeSymlinkDirectories: () => []` stub to each so
the symlink loop is a no-op in tests.

`enter-worktree.test.ts` and `agent/agent.test.ts` intentionally
skipped — they mock `GitWorktreeService.createUserWorktree` outright,
so the method call never fires in their code paths. Adding the stub
there would be defensive speculation. If a future test exercises
the real path, it'll surface there too and we'll add it then.

Local: core tools tests now 123 passed (was 9 failed / 114 passed
on CI run 26213122427 against commit 000c9f63).

* fix(worktree): normalize repoRoot path separators + disable autocrlf in tests

Round 5 of CI: Windows-only test failures on the latest HEAD. Two
unrelated Windows-specific bugs, both in / around worktreeStartup.

1. `setupStartupWorktree` stored the raw `getRepoTopLevel()` output
   in `context.repoRoot`. git always emits POSIX paths via
   `--show-toplevel` (`C:/Users/...`), so on Windows the value was
   forward-slash where `fs.realpath` and `path.join` produce
   backslash. The sidecar's `originalCwd` field got the
   inconsistent format and a downstream `expect(...).toBe(tempRepo)`
   in the round-trip test compared `C:/Users/.../tmp/...` against
   `C:\Users\.../tmp/...`.

   Wrap the value in `path.resolve()` to normalize to the
   platform-native separator before storing. Downstream consumers
   (`path.join(session.originalCwd, '.qwen', 'worktrees')` in
   `restoreWorktreeContext`, `new GitWorktreeService(originalCwd)`
   in `AppContainer`) already handle either format, so no migration
   concern for older sidecars.

2. `makeTempRepo` in worktreeStartup.test.ts didn't configure
   `core.autocrlf=false`. On Windows runners the default is `true`,
   so files committed and pushed to the test's fake-remote `pull/<N>/head`
   ref get CRLF-converted on the worktree's checkout. The PR-content
   assertion `expect(prFile).toBe('from PR 42\n')` then failed with
   `'from PR 42\r\n'`.

   Add `core.autocrlf=false` + `core.eol=lf` to the temp-repo setup
   so test files round-trip byte-for-byte regardless of host platform.

Local mac: cli worktreeStartup 15/15 still pass. Windows verification
deferred to CI.

* fix(worktree): reject '..' segments + use junction on Windows

Two Copilot findings on symlinkConfiguredDirectories (PR #4381 round 3):

1. The settingsSchema description, docs/users/features/worktree.md, and
   WorktreeSettings JSDoc all promise that entries containing `..` are
   rejected — but the post-resolve isWithinRoot check accepted
   `foo/../bar` (resolves to `bar`, inside the repo). Add a literal `..`
   segment check before path.resolve so the code matches the contract.

2. On Windows, fs.symlink(..., 'dir') requires
   SeCreateSymbolicLinkPrivilege (admin / Developer Mode) and EPERMs on
   default consumer installs. Use 'junction' for directory entries on
   win32 — junctions are reparse points that achieve the same semantics
   without elevation. Keep 'dir' on POSIX and 'file' for non-directory
   sources (no junction-equivalent for files; rare path).

Adds an integration test exercising `foo/../bar` to lock in the
syntactic guard; existing absolute-path and traversal tests already
covered the other rejection forms.

* fix(worktree): PR-worktree HEAD-SHA capture + symlink guard tests

Three findings from wenshao round 4 (PR #4381):

1. For --worktree=#42 (PR worktrees), originalHeadCommit was captured
   from the parent repo's HEAD via getCurrentCommitHash() — but the
   worktree branches off FETCH_HEAD (the PR tip), not main. Downstream,
   WorktreeExitDialog's `rev-list <originalHeadCommit>..HEAD` would
   count every commit in the fetched PR as "new work this session"
   alongside the user's actual commits.

   Same root cause covers the FETCH_HEAD TOCTOU window: between
   `git fetch origin pull/<N>/head` and `git worktree add ... FETCH_HEAD`,
   a concurrent `git fetch` from any other process sharing this repo
   could overwrite .git/FETCH_HEAD, causing the worktree to branch off
   an unrelated commit.

   Fix: add GitWorktreeService.resolveRef(ref) that returns a 40-char
   SHA (or null). In setupStartupWorktree, immediately after
   fetchPullRequestRef succeeds, resolve FETCH_HEAD to an immutable
   SHA; pass that SHA both as the baseRef to createUserWorktree (closes
   the TOCTOU) AND as originalHeadCommit in the returned context
   (closes the exit-dialog miscount). Fail-close on null resolve.

2. Orphaned JSDoc block at gitWorktreeService.ts:1035-1048 — originally
   wrote validateUserWorktreeSlug's docs, stranded above parsePRReference
   after that function was inserted between them. Move the block down to
   sit immediately above validateUserWorktreeSlug at its current line.

3. `.git` / `.qwen` symlink rejection guards (~20 lines of security-
   critical code at gitWorktreeService.ts:1640-1655) had no regression
   tests — only absolute paths, `..` traversal, isWithinRoot escapes,
   and missing sources were covered. Add two integ tests in
   gitWorktreeService.symlinks.integ.test.ts: one asserts `.git/hooks`
   is refused, one asserts `.qwen/projects` is refused.

Also extends the existing PR-worktree integration test in
worktreeStartup.test.ts to assert originalHeadCommit equals the
resolved FETCH_HEAD SHA AND does NOT equal the parent repo's main HEAD
— the assertion would fail loudly if the new SHA-capture path were
reverted.

* fix(worktree): realpath check on symlinkDirectories source + dest paths

Security fix from PR #4381 round 7 (wenshao/qwen3.7-max). The lexical
isWithinRoot + .git/.qwen blocklist checks in symlinkConfiguredDirectories
all operated on path.resolve(repoRoot, raw) — a STRING operation that
doesn't follow symlinks. A committed (or out-of-band) symlink at
<repo>/node_modules pointing into .git would pass every gate:

  1. path.resolve gives `<repo>/node_modules` (lexical, passes
     isWithinRoot against repo root).
  2. The .git/.qwen blocklists also see the lexical path — they don't
     detect that the realpath chains into .git.
  3. fs.stat() follows the symlink and succeeds against .git/.
  4. fs.symlink writes `<worktree>/node_modules → <repo>/node_modules`,
     which OS-side resolves through to <repo>/.git. Any tool inside the
     worktree that writes to node_modules/hooks/post-merge then has RCE
     on the next hook-firing git operation.

Fix: after fs.stat succeeds, fs.realpath the source and RE-RUN the three
containment checks against the realpath. Refuse on any escape. Use the
realpath (not the lexical sourceAbs) as the symlink target so the new
link is one-hop canonical rather than preserving the chain.

Also closes the dest-side variant of the same root cause — flagged in
round 4 thread #5 (declined then as overthinking) but now in scope per
the skill's iteration rule (two consecutive rounds raising the same
root-cause class). path.join(worktreePath, raw) is also lexical: if
git worktree add materialized a committed worktree-level symlink (e.g.
HEAD ships tools → /etc), then fs.mkdir / fs.symlink for a nested entry
like "tools/cache" writes OUTSIDE the worktree. Realpath the dest
parent before mkdir and refuse if it escapes the worktree.

New integ test covers both source-side variants (escape-to-git via
out-of-band symlink + escape-to-outside-dir) in one block. Was RED
against the pre-fix code: <wt>/escape-to-git was created as a symlink
that chained into the source repo's .git. GREEN after the fix.

* fix(worktree): canonicalise repo root before symlinkDirectories checks

Round-7's source-side realpath fix introduced a canonical-vs-lexical
mismatch: `repoRootAbs = path.resolve(this.sourceRepoPath)` is purely
lexical, while `realSource = await fs.realpath(sourceAbs)` is canonical.
On macOS where `/tmp → /private/tmp` and `/var → /private/var` are
ubiquitous, and on any Linux/Windows setup where the user's checkout
sits behind a symlink, the prefixes diverge at the symlink boundary and
`isWithinRoot(realSource, repoRootAbs)` silently rejects every
configured entry.

Production callers (worktreeStartup.ts, EnterWorktreeTool,
agent isolation) all pass the lexical path returned by
`git rev-parse --show-toplevel`. The integ tests masked the bug because
the shared `beforeEach` did `repoRoot = await fs.realpath(dir)` upfront.

Round 8 fix:

- Hoist `repoRootAbs`, `gitDirAbs`, `qwenDirAbs`, and `realWorktreePath`
  outside the for-loop — they're loop invariants and were being
  recomputed once per entry.
- `await fs.realpath(this.sourceRepoPath)` for `repoRootAbs` so every
  containment check below is canonical-vs-canonical. The derived
  `gitDirAbs` / `qwenDirAbs` blocklist paths inherit the canonical
  prefix automatically. `sourceAbs = path.resolve(repoRootAbs, raw)`
  inherits it too, so the early lexical reject paths (absolute, `..`,
  repo-root equality, isWithinRoot) stay self-consistent.
- Fail-close: if the repo root itself doesn't realpath (deleted /
  inaccessible), bail out of the entire symlink loop rather than
  continuing with comparisons we can't trust. Non-destructive — the
  worktree was created earlier by `git worktree add`.

New integ test provisions the production shape: a symlink path used
as `sourceRepoPath`, distinct from its canonical realpath. RED on the
pre-fix code (assertion fired with "symlinkDirectories entry was
silently rejected — canonical vs lexical isWithinRoot mismatch"),
GREEN after.
2026-05-27 17:04:51 +08:00

26 KiB
Raw Permalink Blame History

Worktree 通用能力设计

问题陈述

qwen-code 目前仅有面向 Arena 多模型对比场景的内部 worktree 实现(GitWorktreeService),用户无法在普通会话中使用 worktree 隔离工作AgentTool 也不支持为 subagent 创建隔离的 worktree 环境。

目标是将 worktree 做成通用能力,支持用户会话级隔离和 Agent 级隔离,同时保证现有 Arena 功能体验完全不变。

现状对比

功能 qwen-code claude-code 阶段
EnterWorktree 工具 Phase A
ExitWorktree 工具 Phase A
AgentTool isolation: 'worktree' Phase B
过期 worktree 自动清理 Phase B
worktree 会话状态持久化与恢复 Phase C
Post-creation setuphooks 配置) Phase C
StatusLine worktree 状态展示 Phase C
WorktreeExitDialog退出提示 Phase C
--worktree CLI 启动标志 Phase D
符号链接目录node_modules 等) Phase D
PR 引用(--worktree=#123 Phase D
sparse checkout Future
tmux 集成 Future
Arena 多模型 worktree 隔离 qwen 独有)
脏状态覆盖stash + copy
Baseline commit 追踪 qwen 独有)

设计原则

worktree 是通用能力Arena 是其上层应用。

  • 通用 worktree 层:EnterWorktree/ExitWorktree 工具、AgentTool isolation 参数、会话状态管理、自动清理
  • Arena 层:多模型并行调度、worktreeBaseDir 自定义路径、批量创建与 diff 对比,继续使用 GitWorktreeService.setupWorktrees() 的现有逻辑,不受通用层改动影响

AgentTool 的 isolation: 'worktree' 只走通用路径Arena 内部不经过这个参数创建 worktree两者路径独立。

路径与配置

通用 worktree 路径

EnterWorktree 工具或 AgentTool isolation: 'worktree' 创建的 worktree 固定存放在:

{git 仓库根}/.qwen/worktrees/{slug}

路径不可配置。slug 命名规则:

  • 用户会话 worktree用户指定名称或自动生成格式{形容词}-{名词}-{4位随机}
  • Agent worktreeagent-{7位随机 hex}

Arena worktree 路径(已有,保持不变)

Arena 的 worktree 路径由 agents.arena.worktreeBaseDir 控制,默认 ~/.qwen/arenaArenaManager.ts:125),与通用路径完全独立,不做任何改动。

扩展配置

配置项 类型 用途 阶段
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 不新增任何配置项。

工具设计

EnterWorktree

触发条件: 用户明确说 "start a worktree"、"use a worktree"、"create a worktree" 等词语。不应在用户说"修复 bug"、"开发功能"时自动触发。

输入 schema

name?: string  // 可选slug 格式:字母/数字/点/下划线/破折号,最大 64 字符

行为:

  1. 验证当前未在 worktree 中(防止嵌套)
  2. 解析到 git 仓库根(处理已在子目录的情况)
  3. 调用 GitWorktreeService 创建 worktree路径为 .qwen/worktrees/{slug}
  4. 将 worktree 会话写入 SessionService
  5. 切换工作目录到 worktree 路径
  6. 清除文件缓存

输出: worktreePathworktreeBranchmessage

ExitWorktree

触发条件: 用户说 "exit the worktree"、"leave the worktree"、"go back" 等。

输入 schema

action: 'keep' | 'remove'
discard_changes?: boolean  // 仅 action='remove' 时有效

安全守卫:

  • 仅操作本会话通过 EnterWorktree 创建的 worktree
  • action='remove' 且存在未提交变更时,拒绝执行(除非 discard_changes: true

行为:

  • keep:清空会话中的 worktree 状态,保留 worktree 目录和分支,恢复原始工作目录
  • remove:删除 worktree 目录,删除对应 git 分支,清空会话状态,恢复原始工作目录

输出: actionoriginalCwdworktreePathworktreeBranch

用户触发方式

方式 示例 实现阶段
会话中明确请求 用户说 "在 worktree 中开始工作" → 模型调用 EnterWorktree Phase A
Agent 隔离 模型为 subagent 设置 isolation: 'worktree' Phase B
CLI 启动标志 qwen --worktree my-feature Phase D

无斜杠命令。会话中 worktree 的触发依赖用户明确提及,isolation: 'worktree' 才是模型自主决策的场景。

分阶段实现计划

Phase A核心工具用户会话级 worktree

目标: 用户能在会话中进入 / 退出 worktree。

要实现的功能:

  • EnterWorktree 工具:创建 worktree切换工作目录记录会话状态
  • ExitWorktree 工具keep / remove 两种退出方式,安全守卫
  • GitWorktreeService 扩展:新增面向单用户会话的 createUserWorktree() / removeUserWorktree() 方法,复用现有 git 操作逻辑,不改动 Arena 使用的批量接口
  • SessionService 扩展:新增 WorktreeSession 字段,记录 { slug, worktreePath, worktreeBranch, originalCwd, originalBranch }--resume 时恢复 worktree 工作目录
  • 工具 prompt为每个工具编写使用说明明确何时调用、何时不调用

影响文件:

文件 变更类型
packages/core/src/tools/tool-names.ts 新增 ENTER_WORKTREEEXIT_WORKTREE 常量
packages/core/src/tools/EnterWorktreeTool/ 新建目录:EnterWorktreeTool.tsprompt.ts
packages/core/src/tools/ExitWorktreeTool/ 新建目录:ExitWorktreeTool.tsprompt.ts
packages/core/src/services/gitWorktreeService.ts 新增用户会话级接口(不改动 Arena 接口)
packages/core/src/services/sessionService.ts 新增 WorktreeSession 字段及读写方法
packages/core/src/tools/ 注册入口 注册新工具

不在 Phase A 范围内:

  • Agent 隔离Phase B
  • hooks 配置等 post-creation setupPhase C
  • UI 状态展示Phase C

Phase BAgent 隔离AgentTool isolation: 'worktree'+ 描述更新

目标: 模型可为 subagent 创建临时隔离 worktreeagent 结束后自动清理;同步更新受影响的工具描述和提示词。

要实现的功能:

Agent 隔离核心:

  • AgentTool 新增 isolation?: 'worktree' 参数
  • Agent 启动时创建临时 worktreeslugagent-{7hex},路径:.qwen/worktrees/agent-{7hex}
  • Agent 结束后:无变更则自动删除;有变更则保留,将路径和分支返回在结果中
  • 过期 worktree 自动清理:扫描 .qwen/worktrees/,匹配 agent-{7hex} 模式,超过 30 天且无未推送提交则删除fail-closed 策略

描述与提示词更新:

  • AgentTool description 补充 isolation: 'worktree' 参数说明(参考 claude-code AgentTool/prompt.ts:272
  • 新增 buildWorktreeNotice():当 fork subagent 在 worktree 中运行时,向其注入上下文提示,说明其处于隔离 worktree、路径继承自父 agent、编辑前需重新读取文件参考 claude-code forkSubagent.ts:buildWorktreeNotice

无需改动:

  • review skillSKILL.mdreview 使用独立机制(路径 .qwen/tmp/review-pr-<n>,通过 qwen review fetch-pr 命令创建),与通用 worktree 路径和机制完全不同,不存在混淆

Arena 兼容保证: Arena 内部不经过 isolation 参数创建 worktree此改动不触碰 Arena 代码路径。

影响文件:

文件 变更类型
packages/core/src/tools/agent/agent.ts 新增 isolation 参数及 worktree 创建/清理逻辑
packages/core/src/tools/agent/fork-subagent.ts 新增 buildWorktreeNotice() 并在 worktree 模式下注入
packages/core/src/services/gitWorktreeService.ts 新增 createAgentWorktree() / removeAgentWorktree()
packages/core/src/services/worktreeCleanup.ts 新建:过期 worktree 自动清理逻辑

Phase C会话完整性SessionService 持久化 + UI 安全网)

目标: worktree 状态在会话中断后可恢复,用户在界面上始终知道自己在哪个 worktree 里,退出会话时有安全提示。

要实现的功能:

SessionService worktree 状态持久化 + --resume 恢复:

  • SessionService 扩展 WorktreeSession 字段,记录 { slug, worktreePath, worktreeBranch, originalCwd, originalBranch }
  • EnterWorktreeTool 调用 sessionService.setWorktreeSession() 写入状态
  • ExitWorktreeTool 调用 sessionService.clearWorktreeSession() 清除状态
  • --resume 启动路径读取该字段,恢复 targetDir 并向模型注入上下文提示

Post-creation setup

  • 创建 worktree 后自动执行 git config core.hooksPath <mainRepo>/.git/hooks,确保 worktree 内的提交与主仓库 hooks 行为一致

StatusLine worktree 展示:

  • UIStateContext 新增 activeWorktree 字段(从 session 状态读取),在会话进入 / 退出 worktree 时更新
  • StatusLineCommandInput payload 新增 worktree?: { slug: string; branch: string } 字段,供用户 statusline 脚本使用
  • FooteractiveWorktree 非空时内置展示一行 ⎇ <branch> (<slug>),无需用户配置 statusline 脚本即可获得基本可见性

WorktreeExitDialog

  • 新增 WorktreeExitDialog.tsx 组件,参考现有 Dialog 写法
  • 修改退出键Ctrl+C / Ctrl+D处理逻辑检测到 activeWorktree 非空时,拦截第二次确认,展示 Dialog 提示用户选择 keep 或 remove
  • keep / remove 操作复用 ExitWorktreeTool 的现有路径

影响文件:

文件 变更类型
packages/core/src/services/sessionService.ts 新增 WorktreeSession 字段及读写方法
packages/core/src/tools/enter-worktree.ts 调用 sessionService.setWorktreeSession()
packages/core/src/tools/exit-worktree.ts 调用 sessionService.clearWorktreeSession()
packages/core/src/services/gitWorktreeService.ts createUserWorktree() / createAgentWorktree() 后追加 core.hooksPath 配置
packages/cli/src/ui/contexts/UIStateContext.tsx 新增 activeWorktree 字段及 set/clear action
packages/cli/src/ui/hooks/useStatusLine.ts StatusLineCommandInput 新增 worktree 字段
packages/cli/src/ui/components/Footer.tsx 内置 worktree 行展示
packages/cli/src/ui/components/WorktreeExitDialog.tsx 新建
packages/cli/src/ui/components/DialogManager.tsx 注册 WorktreeExitDialog
packages/cli/src/ui/components/ExitWarning.tsx 或退出键处理 检测 activeWorktree 并拦截退出

Phase D启动时配置--worktree CLI 标志 + 目录符号链接 + PR 引用)

目标: 支持在启动时直接进入 worktree、通过目录符号链接减少大型项目的磁盘开销以及通过 PR 引用快速基于一个 pull request 创建 worktree。

范围: 三个功能在一个阶段一起落地,因为它们都挂在同一个启动入口上,且 symlink / PR fetch 两者都需要在 worktree 创建之后立即执行 — 单独拆分会重复改 bootstrap 序列。

D-1--worktree [name] CLI 启动标志

参数形态: yargs 选项接受三种形式:

形式 行为
qwen --worktree bare flag自动生成 slug{形容词}-{名词}-{6hex}
qwen --worktree my-name 显式 slug沿用 EnterWorktreeTool 的 slug 校验规则
qwen --worktree=my-name 等价于上一种

不提供短别名 -wqwen-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()
    • 写入 sidecarwriteWorktreeSession()
    • 设置 process.chdir(worktreePath) 同时 Config.setTargetDir(worktreePath)
    • 同一 worktree 的 re-attach 路径:跳过 git worktree add 并就地 chdirPhase 6 修复)。跨 projectHash 的 --resume × --worktree 组合在 session lookup 阶段会失败,详见下文"与 --resume 的优先级"。
  5. 主循环TUI / headless -p / ACP 三种入口都要走第 4 步)

与 Phase A 简化的差异: Phase A 的 EnterWorktreeTool 修改 Config.targetDir依赖模型从工具结果里读到绝对路径并继续使用。Phase D 的 CLI flag 在启动期就生效,没有运行中的模型上下文需要兼容,所以直接切换 targetDirprocess.cwd() —— 这是更强的隔离保证。两条路径行为不同,需要在用户文档里说明。

退出行为: 复用现有 WorktreeExitDialogPhase 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 到已有 worktreePhase 6 修复)
恢复旧 worktreePhase C 行为sidecar 命中则注入 reminder
sid 出自同一 worktree 有(同一 slugre-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-2worktree.symlinkDirectories 配置项

schema

{
  "worktree": {
    "symlinkDirectories": ["node_modules", "dist", ".turbo"],
  },
}
  • 类型:string[],默认 undefined不开启opt-in
  • 顶层 namespace worktree 是新增的(在 settingsSchema.ts 中按字母序插在 toolsui 之间)
  • 路径相对于主仓库根,绝对路径或包含 .. 的路径被路径遍历守卫拒绝

作用范围: 所有由通用层创建的 worktree包括

  • EnterWorktreeToolPhase A
  • AgentTool isolation: 'worktree'Phase B
  • --worktree CLI flagPhase 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-3PR 引用解析(--worktree=#<N> / 全 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 与分支命名:

  • slugpr-<N>(特殊保留前缀,与用户 slug 区分)
  • 分支:worktree-pr-<N>(沿用 qwen-code 现有 worktree-<slug> 命名规则;不采用 claude-code 的 pr-<N> 直接命名,避免与本地 pr-<N> 分支冲突)

fetch 策略:

git fetch origin pull/<N>/head
→ 用 FETCH_HEAD 作为新 worktree 的 base

不依赖 gh CLI —— 纯 git fetch支持任何 GitHub 实例(公网或企业版),只要 origin 远程指向 GitHub。

错误路径:

场景 错误消息
origin 远程缺失 --worktree=#<N> requires an "origin" remote that points at GitHub.
git fetch 失败 Failed to fetch PR #<N>: 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 就无法创建 worktreeslug 校验失败 中止 启动(与 EnterWorktreeTool 一致)。
  • path traversal symlinkDirectories 项必须解析后仍在 repoRoot 内,否则拒绝该项并 log。
  • PR fetch 超时: 30 秒硬超时,避免无响应的网络拖死启动。
  • cwd 切换的副作用:process.cwd() 之后,相对路径(如 --prompt-file ./foo.txt)的解析会受影响。对策: 在切 cwd 之前先解析所有相对路径参数(具体在 setupStartupWorktree() 入口处做一次 normalize

开放问题

  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 refpull/<N>/merge,即与 base 合并后的 ref而非 head claude-code 选 head,理由是用户通常想看 PR 的实际改动。沿用此选择。

Future高级功能按需实现

以下功能面向更特定的使用场景,当前阶段不纳入排期,待用户需求明确后再评估实现。

功能 说明
sparse checkout worktree.sparsePaths 配置项,大型 monorepo 只 checkout 指定路径,缩短创建时间和磁盘占用
.worktreeinclude 文件 将 gitignore 的文件(.envsecrets.json 等)自动复制进 worktree
tmux 集成 --worktree --tmux 在新 tmux 窗口启动 worktree 会话