qwen-code/docs/users/features/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

22 KiB

Worktrees

Isolate experimental work in a temporary 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:

# 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/<N>/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 <session-id> --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 <id> --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 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:

> 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:

> 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:

> 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-<hex>) 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:

<repoRoot>/.qwen/worktrees/<slug>/         # Working directory
                          ↳ branch worktree-<slug>   # Created off your current branch
  • Slug — letters, digits, dot, underscore, hyphen; max 64 chars. If you don't specify a name, an <adjective>-<noun>-<6hex> slug is auto-generated (e.g. tender-jemison-037f0a). PR references produce pr-<N>.
  • Branch — always worktree-<slug>, 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-<N> 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) 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 <repoRoot>/.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 worktrees, which use a separate path tree under ~/.qwen/arena/.)

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, it also receives a worktree object in the JSON payload piped to stdin:

{
  "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 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 <baseHEAD>..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:

<chatsDir>/<sessionId>.worktree.json

When you launch the CLI with --resume <sessionId> (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: "<slug>" at <path> (branch: <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) <system-reminder> 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 <repoRoot>/.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-<slug> 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:

// ~/.qwen/settings.json or <repo>/.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 <repoRoot>/.qwen/worktrees/.

There is no schema for worktree.sparsePaths yet — that's a roadmap item (see Limitations).

Tool Reference

enter_worktree

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

{ "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.

agentisolation parameter

{
  "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 for the rest of the agent tool reference.

CLI Reference

--worktree [name | #N | url]

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 <adjective>-<noun>-<6hex>, branch worktree-<slug>, base = current branch.
Plain slug Branch worktree-<slug>, base = current branch. Slug validation: letters/digits/dot/underscore/hyphen, max 64 chars.
#N or <github-url>/pull/N Slug pr-<N>, branch worktree-pr-<N>, base = FETCH_HEAD after git fetch origin pull/<N>/head (30s timeout).

--worktree cannot be combined with --acp / --experimental-acp.

When --worktree is combined with --resume <session-id>, 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 #<N>: the PR does not exist on origin
30s network timeout Failed to fetch PR #<N>: 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 <sid> --worktree second where <sid> 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). 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 <chatsDir>/<sessionId>.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-<hex> worktrees keep piling up. The 30-day cutoff is conservative; sweep manually with git worktree list && git worktree remove <path>, or wait — the next CLI startup after the 30-day mark will reap them as long as they are clean and pushed.